decompound 0.2.0 → 0.3.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: da38e3a4783149369844c3bf7ec4aaefffe650b24f600eea4c0d5dcf482e8fc9
4
- data.tar.gz: 8e02323ca26643ac63f25b2403b2605a0a46707e67fa810614aa0f26e900ace7
3
+ metadata.gz: cfbd2354f4af5ae69ac9e258b52e658a4ad859b7882d5c60150902dfc1a68480
4
+ data.tar.gz: 27ff07f69553d8467c004967f584f2316c35dada2d738cd7cc6a6edfb269ca0e
5
5
  SHA512:
6
- metadata.gz: '089027fdcbacbd946ffc6698ece09b843225e0956baea46c0f0bdb7f255517c54399a289d63f565a446b61f0332ed3522185a6c430a05605984ead964d3af338'
7
- data.tar.gz: ffcef37ea872e33c59eb8d8d8620555b8631f085b0e9f6bad6ffced58e32966e15113a382a5db6cf32c0b23cbe9e2c48d508532adaf5d704812f04136d5951f8
6
+ metadata.gz: 327855e9992b2e955de67bd03147547ed113c30a60c30e7b0bc819b14224de8f39f660847bf317e491cb0d3c2720dd23efac2a9b142a1a011a2109831d875233
7
+ data.tar.gz: ca70c7dd58def1472d87882b3838099e7ae6ce9b8e85f37dd5278d85fad5c7184925eeda8353bb65c38330f81fafaa60d65415cfa1f6b339f1eda200f345563e
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-07-16
4
+
5
+ - Use binary model format: Reduce memory by searching a packed binary model instead of parsing JSON into hashes. Slower throughput though.
6
+
3
7
  ## [0.2.0] - 2026-07-15
4
8
 
5
9
  - Train bundled model from German Wiktionary nouns (~95% head-detection accuracy on GermaNet compounds)
data/README.md CHANGED
@@ -17,48 +17,36 @@ The bundled model is trained on 127,106 German Wiktionary noun lemmas and their
17
17
 
18
18
  ## Installation
19
19
 
20
- Install the gem and add to the application's Gemfile by executing:
21
-
22
20
  ```bash
23
21
  bundle add decompound
24
22
  ```
25
23
 
26
- If bundler is not being used to manage dependencies, install the gem by executing:
24
+ Or without bundler:
27
25
 
28
26
  ```bash
29
27
  gem install decompound
30
28
  ```
31
29
 
32
- ## Usage
33
-
34
- ```rb
35
- require "decompound"
36
-
37
- word = "Bodenbelag"
38
- parts = Decompound.split(word)
39
- # => ["boden", "belag"]
40
- ```
41
-
42
30
  ## Roadmap
43
31
 
44
- - Improve memory efficiency
45
- - Provide different models (small, medium, large)
32
+ - Provide different models (small, medium, large) (?)
33
+ - Recursive decompounding
34
+ - `Fussbodenbelag` → `Fuss` + `Bodenbelag` → `Fuss` + `Boden` + `Belag`
46
35
 
47
36
  ## Development
48
37
 
49
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
50
-
51
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
38
+ After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
52
39
 
53
40
  ### Release process
54
41
 
55
42
  For each release:
56
43
 
57
44
  1. Update `lib/decompound/version.rb`
58
- 2. Update `CHANGELOG.md`
59
- 3. Run tests and RBS validation
60
- 4. Commit with message `Prepare x.x.x release` and push
61
- 5. Run `bundle exec rake release`
45
+ 2. Run `bundle install` to update `Gemfile.lock`
46
+ 3. Update `CHANGELOG.md`
47
+ 4. Run tests and RBS validation
48
+ 5. Commit with message `Prepare x.x.x release` and push
49
+ 6. Run `bundle exec rake release`
62
50
 
63
51
  ### Training CharSplit probabilities
64
52
 
@@ -73,11 +61,19 @@ ruby bin/train.rb \
73
61
 
74
62
  `--forms lemmas` uses only lemmas, while `--forms nominative` uses lemmas plus nominative inflections. Inflected forms provide the linking-element evidence (e.g. the *-s* in *Einkaufsliste*), so the bundled model uses `all`. The generated JSON contains CharSplit-compatible `prefix`, `infix`, and `suffix` probabilities.
75
63
 
64
+ ### Packing the runtime model
65
+
66
+ The gem ships the probabilities as a binary table (`data/model.bin`), searched in place:
67
+
68
+ ```sh
69
+ ruby bin/pack.rb data/ngram_probs.json data/model.bin
70
+ ```
71
+
76
72
  ## Provenance and credits
77
73
 
78
74
  The noun corpus used to train the bundled probabilities is derived from [German Wiktionary](https://de.wiktionary.org/). It is extracted by [Wiktextract](https://github.com/tatuylonen/wiktextract) and distributed by [Kaikki.org](https://kaikki.org/). Wiktionary textual content is licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) and, where applicable, the GNU Free Documentation License. See [`data/vendor/german-nouns/SOURCE.md`](data/vendor/german-nouns/SOURCE.md) for the pinned source, extraction process, and reproducibility details.
79
75
 
80
- The splitting and training algorithms are Ruby ports of [CharSplit](https://github.com/dtuggener/CharSplit), created by Don Tuggener and described in *Incremental Coreference Resolution for German* (University of Zurich, 2016). CharSplit is MIT licensed; its copyright and license are reproduced in [`LICENSE-CharSplit.txt`](LICENSE-CharSplit.txt).
76
+ The splitting and training algorithms are Ruby ports of [CharSplit](https://github.com/dtuggener/CharSplit), created by Don Tuggener and described in [Incremental Coreference Resolution for German](https://doi.org/10.5167/uzh-124915) (University of Zurich, 2016). CharSplit is MIT licensed; its copyright and license are reproduced in [`LICENSE-CharSplit.txt`](LICENSE-CharSplit.txt).
81
77
 
82
78
  See [`NOTICE.md`](NOTICE.md) for consolidated third-party notices.
83
79
 
data/data/model.bin ADDED
Binary file
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decompound
4
+ # Reads the binary model format (decompound-binary-v1):
5
+ #
6
+ # magic "DCPDv1\n"
7
+ # count uint32 LE
8
+ # bucket_count uint32 LE
9
+ # buckets bucket_count x (key prefix, NUL-padded to 3 bytes + uint32 LE first index)
10
+ # offsets (count + 1) x uint32 LE, byte offsets into the keys blob
11
+ # probabilities count x 3 bytes (prefix, infix, suffix), 255 = missing
12
+ # keys concatenated UTF-8 n-grams, sorted bytewise
13
+ #
14
+ # The file is held as a single string and searched in place, so loaded
15
+ # memory stays near file size instead of materializing millions of Ruby
16
+ # strings and hash entries. The buckets narrow each binary search to the
17
+ # keys sharing the query's first three bytes.
18
+
19
+ class Model
20
+ MAGIC = "DCPDv1\n"
21
+ PREFIX = 0
22
+ INFIX = 1
23
+ SUFFIX = 2
24
+ MISSING = 255
25
+ SCALE = 254.0
26
+
27
+ def self.load(path)
28
+ new(File.binread(path))
29
+ end
30
+
31
+ def initialize(data)
32
+ raise ArgumentError, "not a decompound binary model" unless data.byteslice(0, MAGIC.bytesize) == MAGIC
33
+
34
+ @data = data.freeze
35
+ @count, bucket_count = data.byteslice(MAGIC.bytesize, 8).unpack("VV")
36
+ buckets_at = MAGIC.bytesize + 8
37
+ @offsets_at = buckets_at + (bucket_count * 7)
38
+ @probabilities_at = @offsets_at + ((@count + 1) * 4)
39
+ @keys_at = @probabilities_at + (@count * 3)
40
+ @buckets = read_buckets(data, buckets_at, bucket_count)
41
+ end
42
+
43
+ def probability(ngram, position, default)
44
+ index = find(ngram.b)
45
+ return default unless index
46
+
47
+ code = @data.getbyte(@probabilities_at + (index * 3) + position)
48
+ (code == MISSING) ? default : code / SCALE
49
+ end
50
+
51
+ private
52
+
53
+ def read_buckets(data, buckets_at, bucket_count)
54
+ firsts = bucket_count.times.map { |i| data.byteslice(buckets_at + (i * 7) + 3, 4).unpack1("V") }
55
+ firsts << @count
56
+ buckets = {}
57
+ bucket_count.times do |i|
58
+ buckets[data.byteslice(buckets_at + (i * 7), 3)] = [firsts[i], firsts[i + 1] - 1]
59
+ end
60
+ buckets.freeze
61
+ end
62
+
63
+ def find(query)
64
+ low, high = @buckets[query.byteslice(0, 3).ljust(3, "\0")]
65
+ return nil unless low
66
+
67
+ while low <= high
68
+ mid = (low + high) / 2
69
+ from, upto = @data.byteslice(@offsets_at + (mid * 4), 8).unpack("VV")
70
+ case query <=> @data.byteslice(@keys_at + from, upto - from)
71
+ when 0 then return mid
72
+ when -1 then high = mid - 1
73
+ else low = mid + 1
74
+ end
75
+ end
76
+ nil
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model"
4
+
5
+ module Decompound
6
+ # Compiles the trained JSON probability tables into the binary runtime model, see Decompound::Model
7
+
8
+ class Packer
9
+ POSITIONS = {"prefix" => Model::PREFIX, "infix" => Model::INFIX, "suffix" => Model::SUFFIX}.freeze
10
+
11
+ def pack(probabilities)
12
+ entries = Hash.new { |hash, key| hash[key] = [Model::MISSING] * 3 }
13
+ POSITIONS.each do |position, slot|
14
+ probabilities.fetch(position).each do |ngram, probability|
15
+ entries[ngram.b][slot] = (probability.clamp(0.0, 1.0) * Model::SCALE).round
16
+ end
17
+ end
18
+
19
+ keys = entries.keys.sort!
20
+ offsets = []
21
+ blob = +""
22
+ buckets = +""
23
+ bucket_count = 0
24
+ previous_prefix = nil
25
+ keys.each_with_index do |key, index|
26
+ prefix = key.byteslice(0, 3).ljust(3, "\0")
27
+ if prefix != previous_prefix
28
+ buckets << prefix << [index].pack("V")
29
+ bucket_count += 1
30
+ previous_prefix = prefix
31
+ end
32
+ offsets << blob.bytesize
33
+ blob << key
34
+ end
35
+ offsets << blob.bytesize
36
+
37
+ Model::MAGIC.b +
38
+ [keys.length, bucket_count].pack("VV") +
39
+ buckets +
40
+ offsets.pack("V*") +
41
+ keys.flat_map { |key| entries.fetch(key) }.pack("C*") +
42
+ blob
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Decompound
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/decompound.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require_relative "decompound/version"
4
+ require_relative "decompound/model"
5
5
 
6
6
  module Decompound
7
- NGRAM_PATH = File.expand_path("../data/ngram_probs.json", __dir__)
7
+ MODEL_PATH = File.expand_path("../data/model.bin", __dir__)
8
8
  FUGEN_S_ENDINGS = %w[ts gs ks hls ns].freeze
9
9
 
10
10
  def self.split(word)
@@ -20,7 +20,7 @@ module Decompound
20
20
  end
21
21
 
22
22
  def self.split_candidates(word)
23
- probabilities = ngram_probabilities
23
+ model = self.model
24
24
 
25
25
  (3...(word.length - 2)).map do |position|
26
26
  left = word[0...position]
@@ -28,11 +28,12 @@ module Decompound
28
28
  left_ngram = without_fugen_s(left)
29
29
  right_ngram = without_fugen_s(right)
30
30
 
31
- suffix_probability = probabilities.fetch("suffix").fetch(left_ngram, -1)
32
- prefix_probability = probabilities.fetch("prefix").fetch(right_ngram, -1)
33
- infix_probability = (3..(word.length + 1)).filter_map do |length|
34
- ngram = right[0, length]
35
- probabilities.fetch("infix").fetch(ngram, 1) unless ngram.empty?
31
+ suffix_probability = model.probability(left_ngram, Model::SUFFIX, -1)
32
+ prefix_probability = model.probability(right_ngram, Model::PREFIX, -1)
33
+ # CharSplit scans lengths up to word.length + 1; slices past the end of
34
+ # +right+ all yield +right+ itself, so stopping there is equivalent.
35
+ infix_probability = (3..right.length).map do |length|
36
+ model.probability(right[0, length], Model::INFIX, 1)
36
37
  end.min
37
38
 
38
39
  [prefix_probability - infix_probability + suffix_probability, left, right]
@@ -48,8 +49,8 @@ module Decompound
48
49
  end
49
50
  private_class_method :without_fugen_s
50
51
 
51
- def self.ngram_probabilities
52
- @ngram_probabilities ||= JSON.parse(File.read(NGRAM_PATH, encoding: "utf-8"))
52
+ def self.model
53
+ @model ||= Model.load(MODEL_PATH)
53
54
  end
54
- private_class_method :ngram_probabilities
55
+ private_class_method :model
55
56
  end
metadata CHANGED
@@ -1,34 +1,21 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: decompound
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Camillo Visini
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: json
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '2.9'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '2.9'
11
+ dependencies: []
26
12
  description: A Ruby gem for splitting German compound words into their constituent
27
13
  parts
28
14
  executables: []
29
15
  extensions: []
30
16
  extra_rdoc_files: []
31
17
  files:
18
+ - ".ruby-version"
32
19
  - CHANGELOG.md
33
20
  - Justfile
34
21
  - LICENSE-CharSplit.txt
@@ -36,11 +23,14 @@ files:
36
23
  - NOTICE.md
37
24
  - README.md
38
25
  - Rakefile
26
+ - data/model.bin
39
27
  - data/ngram_probs.json
40
28
  - lib/charsplit/german_nouns.rb
41
29
  - lib/charsplit/ngram_counter.rb
42
30
  - lib/charsplit/trainer.rb
43
31
  - lib/decompound.rb
32
+ - lib/decompound/model.rb
33
+ - lib/decompound/packer.rb
44
34
  - lib/decompound/version.rb
45
35
  - script/download_german_nouns.rb
46
36
  - sig/decompound.rbs