libdictenstein 4.0.0.rc.4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 010cb3470993511a75d84982f225e366d1d9d31232b858539758a675d51132ba
4
+ data.tar.gz: 53a12f44a10d6fb1059e8322e5608f48d2a28f8bf76b2f9bc8889e4433b56656
5
+ SHA512:
6
+ metadata.gz: 98bccac45121c8b16fbcefb357b0f9f95c67c2873c35f8e67cb66fa96ad1e5c99108598a3435c8bea008dfce8133765a7005c5a6513bd7474c648e8fbc148850
7
+ data.tar.gz: ff10f78c20b60cf9a150aa697e1072c34a6e9c20d5a92fc2af493fc517b93d856e2e8615ea8b3b59a39c1a766a3fe15912c7eb02d23a63d322d4af10861d05bd
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # Vinary Tree libdictenstein for Ruby
2
+
3
+ The gem exposes full DynamicDAWG CRUD, immutable DoubleArrayTrie construction,
4
+ SCDAWG substring search, persistent ARTrie CRUD/checkpoint/reopen, and persistent
5
+ vocabulary reverse lookup. Every object implements `with_resource`, allowing an
6
+ independently packaged liblevenshtein transducer to retain it in O(1).
7
+
8
+ Calls acquire only a short lifetime lease; operations on the same dictionary
9
+ are not serialized. The project-owned native resource advertises parallel and
10
+ reentrant access automatically.
11
+
12
+ Every dictionary is an `Enumerable` over immutable-revision `Entry` records:
13
+
14
+ ```ruby
15
+ dictionary = VinaryTree::Libdictenstein::DynamicDawg.new
16
+ dictionary.put("cat", 0)
17
+ dictionary.put("cut", nil)
18
+
19
+ dictionary.each do |entry|
20
+ p [entry.key, entry.value]
21
+ break if entry.key == "cat" # ensure closes the native cursor
22
+ end
23
+
24
+ keys = dictionary.keys
25
+ values = dictionary.values
26
+ snapshot = dictionary.entries
27
+ ```
28
+
29
+ `each` returns an `Enumerator` without a block. `entry_stream` exposes manual
30
+ `next`, `cancel`, and `close` for pull-driven bounded traversal.
31
+
32
+ <!-- BEGIN GENERATED BINDING OPERATIONS; DO NOT EDIT -->
33
+
34
+ ## Support and package contract
35
+
36
+ | Property | Contract |
37
+ |---|---|
38
+ | Binding | Ruby |
39
+ | Languages/runtime | Ruby 3.3+ |
40
+ | Support tier | Tier 2 |
41
+ | Distribution | RubyGems `libdictenstein` |
42
+ | Native boundary | Fiddle over the stable C ABI |
43
+ | Canonical facade source | [`bindings/ruby/lib/vinary_tree/libdictenstein`](../../bindings/ruby/lib/vinary_tree/libdictenstein) |
44
+
45
+ All tiers implement the same ownership, snapshot, status, and compatibility
46
+ laws. The tier controls release gating rather than semantic quality. Start with
47
+ the [producer documentation hub](../../docs/bindings/README.md), then use the
48
+ [`ldict_*` C ABI reference](../../docs/bindings/c-abi-reference.md) for exact
49
+ preconditions, statuses, thread-safety, and complexity.
50
+
51
+ ![A host facade owns a project handle while exported snapshots cross projects only as retained versioned resources.](../../docs/diagrams/abi-producer-component.svg)
52
+
53
+ ## Installation and native loading
54
+
55
+ Install the distribution named above and its exact `vinary-tree-interop`
56
+ dependency. Published managed packages carry or resolve supported native
57
+ artifacts; source builds use the release library from `target/release` or the
58
+ installed CMake/pkg-config package. Diagnose loading in this order: toolchain
59
+ version, OS/CPU artifact, dependent package pin, loader path, then ABI/API
60
+ handshake. Never silently load an arbitrary same-named system library.
61
+
62
+ ## Executable example and verification
63
+
64
+ The canonical checked example is [`bindings/ruby/test/test_conformance.rb`](../../bindings/ruby/test/test_conformance.rb). CI runs
65
+ the public package path with:
66
+
67
+ ```sh
68
+ ruby -Ibindings/ruby/lib bindings/ruby/test/test_conformance.rb
69
+ ```
70
+
71
+ The example is also conformance evidence: it uses public constructors, checks
72
+ membership/value behavior, exports a retained resource, and closes every owned
73
+ handle. Cross-project suites pass that resource to liblevenshtein without
74
+ serialization.
75
+
76
+ ## Public API, backends, and data domains
77
+
78
+ | Concept | Semantics |
79
+ |---|---|
80
+ | Dictionary handle | Owns one mutable or immutable backend instance and exposes kind/capability introspection. |
81
+ | CRUD and batch mutation | Text and `u64` operations preserve optional values; empty-batch and partial-failure behavior follows the C reference. |
82
+ | Persistent maintenance | `checkpoint`, `compact`, and `clear` are capability-gated and report unsupported operations explicitly. |
83
+ | Retained resource | `resource()` lends `vt.dictionary.v1`; a consumer retains and snapshots it independently. |
84
+ | Snapshot | Immutable revision with stable node identifiers, exact domains, bounded edge pages, and optional mapped values. |
85
+
86
+ Dynamic DAWG supports mutable finite-term dictionaries; double-array tries are
87
+ read-optimized static structures; SCDAWG indexes substrings; persistent ARTrie
88
+ and vocabulary stores provide durable byte/Unicode/`u64` domains. Select by
89
+ reported kind and capabilities rather than assuming every operation exists.
90
+
91
+ Text APIs validate UTF-8 and traverse Unicode scalar values. Byte APIs retain
92
+ arbitrary octets. Token APIs preserve the full `u64` range. Optional dictionary
93
+ values are represented separately from terminal membership, so `None` is not a
94
+ sentinel and empty terms remain valid when supported.
95
+
96
+ ## Native collection surface
97
+
98
+ Every dictionary includes `Enumerable`. `each` returns an `Enumerator`
99
+ without a block and yields host-owned `Entry` records in lexical order; its
100
+ `ensure` path closes the cursor after exhaustion, `break`, or exception.
101
+ `entry_stream` also exposes explicit `next`, `cancel`, and `close`, while
102
+ `entries`, `keys`, and `values` provide materialized snapshot idioms. Binary
103
+ strings, UTF-8 strings, and `Array<Integer>` preserve the three unit domains;
104
+ `nil` remains distinct from every mapped integer.
105
+
106
+ The gem executable keeps construction and warmup outside the timed drain and
107
+ prints one JSON record. Run its materialized, streaming, and early-cancel arms
108
+ over the shared deterministic corpus with:
109
+
110
+ ```sh
111
+ ruby bindings/ruby/bin/libdictenstein-collection-profile --arm materialized --entries 4096
112
+ ruby bindings/ruby/bin/libdictenstein-collection-profile --arm stream --entries 65536 --batch-size 256
113
+ ruby bindings/ruby/bin/libdictenstein-collection-profile --arm stream-cancel --entries 65536 --batch-size 64 --early-cancel 64
114
+ ```
115
+
116
+ The pure Rust producer is the semantic and performance baseline: generic
117
+ snapshot traversal, borrowed and snapshot-owning `IntoIterator`, optimized bulk
118
+ `FromIterator`/`Extend` where infallible, named fallible variants for persistent
119
+ stores, deterministic order, and reusable fold/visitor paths. Read the local
120
+ [Rust API audit](../../docs/bindings/rust-api-idioms.md) and the family
121
+ [collection-protocol design](https://github.com/vinary-tree/liblevenshtein-rust/blob/master/docs/bindings/collection-protocols.md).
122
+
123
+ Public spelling must remain native to this ecosystem. The shared engine
124
+ standardizes laws and batching; it does not expose C handles, vtables, leases,
125
+ or status codes to ordinary application code. Documentation may mark a protocol
126
+ as shipped only after its language conformance and performance gates pass.
127
+
128
+ ## Ownership, snapshots, and resource handoff
129
+
130
+ Prefer block forms or `ensure { dictionary.close }`; close persistent stores explicitly.
131
+
132
+ An exported resource is borrowed until its base-vtable `retain` succeeds. A
133
+ captured snapshot arrives owning one retain and may outlive the mutable
134
+ dictionary. Later inserts, updates, removals, compaction, or checkpoints do not
135
+ alter a pinned revision. Every successful retain has exactly one release, and
136
+ failed construction transfers no ownership.
137
+
138
+ ## Errors and failure containment
139
+
140
+ Failures become typed Ruby exceptions with status and diagnostic. Branch on the typed status or exception, not diagnostic text.
141
+ Invalid UTF-8, domain mismatch, unsupported capability, closed handle, bad
142
+ path, allocation failure, provider fault, I/O failure, and contained panic are
143
+ distinct cases. Copy thread-local diagnostics before another native call.
144
+
145
+ ## Concurrency and reentrancy
146
+
147
+ Independent handles and immutable snapshots are reentrant. Mutations follow
148
+ the backend's advertised synchronization strategy; one host wrapper must not
149
+ invent a stronger promise. Snapshot capture is a linearization point and never
150
+ permits a torn root/count pair. Do not race close against another operation on
151
+ the same handle, and do not retain callback/paging buffers after return.
152
+
153
+ ## Performance, durability, and marshalling
154
+
155
+ - Use bulk construction for an initially empty dictionary and presorted input
156
+ when available; unordered construction uses the optimized sort-plus-minimal
157
+ path.
158
+ - Batch mutations to amortize foreign-boundary crossings.
159
+ - Export retained resources instead of serializing dictionaries between
160
+ Vinary packages.
161
+ - Keep byte, Unicode, and `u64` domains explicit to avoid transcoding.
162
+ - Treat `checkpoint` as durability, `compact` as representation maintenance,
163
+ and `close` as ownership release; they are not interchangeable.
164
+
165
+ ## Security model
166
+
167
+ Treat paths, terms, values, page offsets, serialized files, and foreign callers
168
+ as untrusted. Validate lengths before allocation, contain panics at the ABI,
169
+ bound diagnostics and batches, prevent path traversal, and reject unknown enum
170
+ values. See the [FFI boundary analysis](../../docs/security/ffi-boundary.md) and
171
+ [family security model](https://github.com/vinary-tree/vinary-tree-interop/blob/master/docs/security-model.md).
172
+
173
+ ## Compatibility and troubleshooting
174
+
175
+ The project ABI, project API revision, family ABI, interface version, package
176
+ version, and persistent format version are separate counters. Negotiate each
177
+ at its documented boundary. For unexpected behavior, record dictionary kind,
178
+ capabilities, unit domain, persistence path, exact status, and copied diagnostic
179
+ before reducing the operation sequence.
180
+
181
+ ## Maintainer checklist
182
+
183
+ 1. Update `bindings/api.json` before changing a public facade or package pin.
184
+ 2. Regenerate headers/constants and run the binding contract gate.
185
+ 3. Extend the executable, negative-path, leak, and cross-project tests.
186
+ 4. Update this guide when ownership, errors, capabilities, or platforms change.
187
+ 5. Render PlantUML headlessly and run math/link/documentation checks.
188
+ 6. Verify staged registry artifacts contain the guide and coherent pins.
189
+
190
+ <!-- END GENERATED BINDING OPERATIONS -->
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Measures the public Ruby collection facade over a deterministic dictionary
4
+ # revision. Corpus construction and warmup are outside the reported interval.
5
+
6
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
7
+
8
+ require "json"
9
+ require "optparse"
10
+ require "vinary_tree/libdictenstein"
11
+
12
+ LD = VinaryTree::Libdictenstein
13
+
14
+ DEFAULT_ENTRIES = 65_536
15
+ DEFAULT_BATCH_SIZE = 256
16
+ DEFAULT_EARLY_CANCEL = 64
17
+ KEY_UNITS = 38
18
+ U64_MASK = (1 << 64) - 1
19
+ ARMS = %w[materialized stream stream-cancel].freeze
20
+
21
+ def parse_arguments(arguments)
22
+ config = {
23
+ arm: nil,
24
+ entries: DEFAULT_ENTRIES,
25
+ passes: 1,
26
+ warmup_passes: 1,
27
+ batch_size: DEFAULT_BATCH_SIZE,
28
+ early_cancel: DEFAULT_EARLY_CANCEL
29
+ }
30
+ parser = OptionParser.new do |options|
31
+ options.on("--arm ARM") { |value| config[:arm] = value }
32
+ options.on("--entries N", Integer) { |value| config[:entries] = value }
33
+ options.on("--passes N", Integer) { |value| config[:passes] = value }
34
+ options.on("--warmup-passes N", Integer) { |value| config[:warmup_passes] = value }
35
+ options.on("--batch-size N", Integer) { |value| config[:batch_size] = value }
36
+ options.on("--early-cancel N", Integer) { |value| config[:early_cancel] = value }
37
+ end
38
+ parser.parse!(arguments)
39
+ raise OptionParser::InvalidArgument, "unexpected positional arguments" unless arguments.empty?
40
+ raise OptionParser::InvalidArgument, "--arm must be #{ARMS.join(', ')}" unless ARMS.include?(config[:arm])
41
+ %i[entries passes batch_size early_cancel].each do |name|
42
+ raise OptionParser::InvalidArgument, "--#{name.to_s.tr('_', '-')} must be positive" unless config[name].positive?
43
+ end
44
+ raise OptionParser::InvalidArgument, "--warmup-passes must be nonnegative" if config[:warmup_passes].negative?
45
+ config
46
+ end
47
+
48
+ def make_corpus(size)
49
+ Array.new(size) do |index|
50
+ [format("collection/%04x/%08x/shared-suffix", index & 0x0fff, index).b, index]
51
+ end
52
+ end
53
+
54
+ def expected_checksum(corpus, limit)
55
+ corpus.sort_by(&:first).first(limit).sum { |key, value| key.bytesize ^ value } & U64_MASK
56
+ end
57
+
58
+ def entry_checksum(entry)
59
+ raise "benchmark expected a byte-domain entry" unless entry.domain == LD::BYTE
60
+ (entry.key.bytesize ^ (entry.value || 0)) & U64_MASK
61
+ end
62
+
63
+ def drain_materialized(dictionary)
64
+ entries = dictionary.entries
65
+ [entries.sum { |entry| entry_checksum(entry) } & U64_MASK, entries.length]
66
+ end
67
+
68
+ def drain_stream(dictionary, batch_size:, limit:, cancel:)
69
+ stream = dictionary.entry_stream(
70
+ max_entries: batch_size,
71
+ max_units: batch_size * KEY_UNITS,
72
+ max_values: batch_size
73
+ )
74
+ checksum = 0
75
+ processed = 0
76
+ begin
77
+ while processed < limit && (entry = stream.next)
78
+ checksum = (checksum + entry_checksum(entry)) & U64_MASK
79
+ processed += 1
80
+ end
81
+ if cancel
82
+ stream.cancel
83
+ elsif processed != limit || !stream.next.nil?
84
+ raise "stream cardinality differs from the generated corpus"
85
+ end
86
+ stream.close
87
+ ensure
88
+ stream.close
89
+ end
90
+ [checksum, processed]
91
+ end
92
+
93
+ def drain(dictionary, config)
94
+ case config[:arm]
95
+ when "materialized"
96
+ drain_materialized(dictionary)
97
+ when "stream"
98
+ drain_stream(dictionary, batch_size: config[:batch_size], limit: config[:entries], cancel: false)
99
+ when "stream-cancel"
100
+ drain_stream(
101
+ dictionary,
102
+ batch_size: config[:batch_size],
103
+ limit: [config[:entries], config[:early_cancel]].min,
104
+ cancel: true
105
+ )
106
+ end
107
+ end
108
+
109
+ def execute(arguments)
110
+ config = parse_arguments(arguments)
111
+ corpus = make_corpus(config[:entries])
112
+ dictionary = LD::DynamicDawg.new(domain: LD::BYTE)
113
+ begin
114
+ inserted = dictionary.put_all(corpus)
115
+ raise "inserted #{inserted} of #{corpus.length} generated entries" unless inserted == corpus.length
116
+
117
+ consumed = config[:arm] == "stream-cancel" ? [config[:entries], config[:early_cancel]].min : config[:entries]
118
+ expected = expected_checksum(corpus, consumed)
119
+ config[:warmup_passes].times do
120
+ checksum, count = drain(dictionary, config)
121
+ raise "warmup checksum or cardinality mismatch" unless count == consumed && checksum == expected
122
+ end
123
+
124
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
125
+ checksum = 0
126
+ config[:passes].times do
127
+ pass_checksum, count = drain(dictionary, config)
128
+ raise "timed checksum or cardinality mismatch" unless count == consumed && pass_checksum == expected
129
+ checksum = (checksum + pass_checksum) & U64_MASK
130
+ end
131
+ elapsed_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started
132
+ raise "aggregate checksum mismatch" unless checksum == (expected * config[:passes]) & U64_MASK
133
+
134
+ puts JSON.generate(
135
+ schema: "libdictenstein.host-collection-traversal.v1",
136
+ runtime: "ruby",
137
+ arm: config[:arm],
138
+ dictionary_entries: config[:entries],
139
+ consumed_entries_per_pass: consumed,
140
+ passes: config[:passes],
141
+ warmup_passes: config[:warmup_passes],
142
+ batch_size: config[:arm] == "materialized" ? nil : config[:batch_size],
143
+ early_cancel: config[:arm] == "stream-cancel" ? config[:early_cancel] : nil,
144
+ elapsed_ns: elapsed_ns,
145
+ checksum: checksum
146
+ )
147
+ ensure
148
+ dictionary.close
149
+ end
150
+ end
151
+
152
+ begin
153
+ execute(ARGV)
154
+ rescue StandardError => error
155
+ warn error.message
156
+ exit 2
157
+ end
@@ -0,0 +1,102 @@
1
+ require "fiddle/import"
2
+ require "rbconfig"
3
+
4
+ module VinaryTree
5
+ module Libdictenstein
6
+ module Native
7
+ extend Fiddle::Importer
8
+
9
+ def self.candidates
10
+ explicit = ENV["LIBDICTENSTEIN_LIBRARY"]
11
+ os, filename = case RbConfig::CONFIG["host_os"]
12
+ when /darwin/ then ["darwin", "liblibdictenstein.dylib"]
13
+ when /mswin|mingw/ then ["windows", "libdictenstein.dll"]
14
+ else ["linux", "liblibdictenstein.so"]
15
+ end
16
+ cpu = case RbConfig::CONFIG["host_cpu"]
17
+ when /x86_64|amd64/ then "x64"
18
+ when /aarch64|arm64/ then "arm64"
19
+ else RbConfig::CONFIG["host_cpu"]
20
+ end
21
+ platform = "#{os}-#{cpu}"
22
+ [explicit, File.expand_path("native/#{platform}/#{filename}", __dir__), filename].compact
23
+ end
24
+ loaded = candidates.find do |candidate|
25
+ begin dlload candidate; true
26
+ rescue Fiddle::DLError then false
27
+ end
28
+ end
29
+ raise Fiddle::DLError, "unable to load libdictenstein (tried #{candidates.join(', ')})" unless loaded
30
+
31
+ Resource = struct ["void* context", "void* vtable"]
32
+ TextEntry = struct [
33
+ "void* data", "size_t len", "uint64_t value", "uint8_t has_value",
34
+ "uint8_t reserved[7]"
35
+ ]
36
+ DictionaryEntry = struct [
37
+ "size_t unit_offset", "size_t unit_len", "size_t value_offset",
38
+ "size_t value_len", "uint64_t reserved"
39
+ ]
40
+ EntryBatchLimits = struct [
41
+ "size_t max_entries", "size_t max_units", "size_t max_values",
42
+ "uint64_t reserved"
43
+ ]
44
+ EntryBatch = struct [
45
+ "void* entries", "size_t entry_count", "void* units",
46
+ "size_t unit_count", "void* values", "size_t value_count",
47
+ "uint64_t generation", "uint64_t reserved"
48
+ ]
49
+ EntriesInfo = struct [
50
+ "uint32_t unit_domain", "uint32_t value_domain", "uint32_t order",
51
+ "uint32_t reserved0", "uint64_t flags", "size_t exact_len",
52
+ "uint64_t identity_producer", "uint64_t identity_revision",
53
+ "uint64_t reserved[2]"
54
+ ]
55
+
56
+ extern "uint32_t ldict_abi_version(void)"
57
+ extern "uint32_t ldict_api_revision(void)"
58
+ extern "const char* ldict_last_error_message(void)"
59
+ extern "uint32_t ldict_dynamic_dawg_new(uint32_t, void*)"
60
+ extern "uint32_t ldict_double_array_trie_new(uint32_t, void*, size_t, void*)"
61
+ extern "uint32_t ldict_scdawg_new(uint32_t, void*)"
62
+ extern "uint32_t ldict_persistent_artrie_create(uint32_t, const void*, size_t, void*)"
63
+ extern "uint32_t ldict_persistent_artrie_open(uint32_t, const void*, size_t, void*)"
64
+ extern "uint32_t ldict_persistent_vocab_create(const void*, size_t, void*)"
65
+ extern "uint32_t ldict_persistent_vocab_open(const void*, size_t, void*)"
66
+ extern "void ldict_dictionary_free(void*)"
67
+ extern "uint32_t ldict_dictionary_resource(void*, void*)"
68
+ extern "uint32_t ldict_dictionary_entries_open(void*, void*, void*)"
69
+ extern "uint32_t ldict_entry_cursor_next(void*, void*, void*)"
70
+ extern "uint32_t ldict_entry_cursor_release(void*, uint64_t)"
71
+ extern "uint32_t ldict_entry_cursor_cancel(void*)"
72
+ extern "uint32_t ldict_entry_cursor_free(void*)"
73
+ extern "uint32_t ldict_dictionary_kind(void*, void*)"
74
+ extern "uint32_t ldict_dictionary_capabilities(void*, void*)"
75
+ extern "uint32_t ldict_dictionary_len(void*, void*)"
76
+ extern "uint32_t ldict_dictionary_insert_text_value(void*, const void*, size_t, uint64_t, uint8_t, void*)"
77
+ extern "uint32_t ldict_dictionary_remove_text(void*, const void*, size_t, void*)"
78
+ extern "uint32_t ldict_dictionary_contains_text(void*, const void*, size_t, void*)"
79
+ extern "uint32_t ldict_dictionary_get_text_value(void*, const void*, size_t, void*, void*, void*)"
80
+ extern "uint32_t ldict_dictionary_insert_u64_value(void*, const void*, size_t, uint64_t, uint8_t, void*)"
81
+ extern "uint32_t ldict_dictionary_remove_u64(void*, const void*, size_t, void*)"
82
+ extern "uint32_t ldict_dictionary_contains_u64(void*, const void*, size_t, void*)"
83
+ extern "uint32_t ldict_dictionary_get_u64_value(void*, const void*, size_t, void*, void*, void*)"
84
+ extern "uint32_t ldict_dictionary_insert_text_batch(void*, void*, size_t, void*)"
85
+ extern "uint32_t ldict_dictionary_clear(void*)"
86
+ extern "uint32_t ldict_dictionary_compact(void*, void*)"
87
+ extern "uint32_t ldict_dictionary_checkpoint(void*)"
88
+ extern "uint32_t ldict_scdawg_contains_substring(void*, const void*, size_t, void*)"
89
+ extern "uint32_t ldict_scdawg_substring_frequency(void*, const void*, size_t, void*)"
90
+ extern "uint32_t ldict_vocab_get_term(void*, uint64_t, void*, size_t, void*, void*)"
91
+
92
+ module_function
93
+ def pointer_output = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP, Fiddle::RUBY_FREE)
94
+ def read_pointer(output) = output[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
95
+ def size_output = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SIZE_T, Fiddle::RUBY_FREE)
96
+ def read_size(output) = output[0, Fiddle::SIZEOF_SIZE_T].unpack1("J")
97
+ def u64_output = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
98
+ def read_u64(output) = output[0, 8].unpack1("Q")
99
+ def byte_output = Fiddle::Pointer.malloc(1, Fiddle::RUBY_FREE)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,5 @@
1
+ module VinaryTree
2
+ module Libdictenstein
3
+ VERSION = "4.0.0.rc.4"
4
+ end
5
+ end
@@ -0,0 +1,475 @@
1
+ require "thread"
2
+ require_relative "libdictenstein/version"
3
+ require_relative "libdictenstein/native"
4
+
5
+ module VinaryTree
6
+ module Libdictenstein
7
+ BYTE = 1
8
+ UNICODE_SCALAR = 2
9
+ U64 = 3
10
+
11
+ # Native ABI version (LDICT_ABI_VERSION); always 1 for this family.
12
+ def self.abi_version = Native.ldict_abi_version
13
+
14
+ # Compatible-additions revision within the ABI version (LDICT_API_REVISION).
15
+ def self.api_revision = Native.ldict_api_revision
16
+
17
+ Lookup = Data.define(:found?, :value)
18
+ Entry = Data.define(:key, :value, :domain)
19
+ EntryInfo = Data.define(:domain, :exact_length, :snapshot_identity)
20
+
21
+ class Error < StandardError
22
+ attr_reader :status
23
+ def initialize(status)
24
+ @status = status
25
+ super("libdictenstein status #{status}: #{Native.ldict_last_error_message.to_s}")
26
+ end
27
+ end
28
+ def self.check(status) = (raise Error, status unless status.zero?)
29
+
30
+ class ConcurrentHandle
31
+ def initialize(pointer)
32
+ @pointer = pointer
33
+ @active = 0
34
+ @closing = false
35
+ @mutex = Mutex.new
36
+ @condition = ConditionVariable.new
37
+ end
38
+
39
+ def with_pointer
40
+ pointer = @mutex.synchronize do
41
+ raise IOError, "dictionary is closed" if @closing || @pointer.zero?
42
+ @active += 1
43
+ @pointer
44
+ end
45
+ yield pointer
46
+ ensure
47
+ @mutex.synchronize do
48
+ @active -= 1
49
+ @condition.broadcast if @active.zero?
50
+ end if pointer
51
+ end
52
+
53
+ def close
54
+ pointer = @mutex.synchronize do
55
+ return 0 if @pointer.zero?
56
+ @closing = true
57
+ @condition.wait(@mutex) until @active.zero?
58
+ result = @pointer
59
+ @pointer = 0
60
+ result
61
+ end
62
+ Native.ldict_dictionary_free(pointer) unless pointer.zero?
63
+ pointer
64
+ end
65
+ end
66
+
67
+ # Shared low-level adapter over one opaque native entry cursor. Every key
68
+ # is copied before a leased batch is released.
69
+ class EntryCursorState
70
+ attr_reader :info
71
+
72
+ def initialize(handle, max_entries:, max_units:, max_values:)
73
+ raise ArgumentError, "max_entries must be positive" unless max_entries.positive?
74
+ raise ArgumentError, "entry batch limits must be nonnegative" if max_units.negative? || max_values.negative?
75
+
76
+ @mutex = Mutex.new
77
+ @cursor = 0
78
+ @leased = false
79
+ @ended = false
80
+ @index = 0
81
+ @limits_memory = Fiddle::Pointer.malloc(Native::EntryBatchLimits.size, Fiddle::RUBY_FREE)
82
+ @limits = Native::EntryBatchLimits.new(@limits_memory)
83
+ @limits.max_entries = max_entries
84
+ @limits.max_units = max_units
85
+ @limits.max_values = max_values
86
+ @limits.reserved = 0
87
+ @batch_memory = Fiddle::Pointer.malloc(Native::EntryBatch.size, Fiddle::RUBY_FREE)
88
+ @batch = Native::EntryBatch.new(@batch_memory)
89
+ info_memory = Fiddle::Pointer.malloc(Native::EntriesInfo.size, Fiddle::RUBY_FREE)
90
+ native_info = Native::EntriesInfo.new(info_memory)
91
+ cursor_output = Native.pointer_output
92
+ handle.with_pointer do |dictionary|
93
+ Libdictenstein.check(
94
+ Native.ldict_dictionary_entries_open(dictionary, cursor_output, info_memory)
95
+ )
96
+ end
97
+ @cursor = Native.read_pointer(cursor_output)
98
+ flags = native_info.flags
99
+ @info = EntryInfo.new(
100
+ native_info.unit_domain,
101
+ flags.anybits?(1) ? native_info.exact_len : nil,
102
+ flags.anybits?(2) ? [native_info.identity_producer, native_info.identity_revision].freeze : nil
103
+ )
104
+ end
105
+
106
+ def next_entry
107
+ @mutex.synchronize do
108
+ return nil if @cursor.zero? || @ended
109
+ begin
110
+ unless @leased
111
+ status = Native.ldict_entry_cursor_next(@cursor, @limits_memory, @batch_memory)
112
+ if status == 1
113
+ @ended = true
114
+ close_locked(cancel: false)
115
+ return nil
116
+ end
117
+ Libdictenstein.check(status)
118
+ @leased = true
119
+ @index = 0
120
+ end
121
+ result = copy_entry(@index)
122
+ @index += 1
123
+ release_locked if @index == @batch.entry_count
124
+ result
125
+ rescue Exception
126
+ close_locked(cancel: true) rescue nil
127
+ raise
128
+ end
129
+ end
130
+ end
131
+
132
+ def cancel
133
+ @mutex.synchronize do
134
+ return nil if @cursor.zero? || @ended
135
+ first_error = native_error(Native.ldict_entry_cursor_cancel(@cursor))
136
+ begin
137
+ release_locked
138
+ rescue Exception => error
139
+ first_error ||= error
140
+ end
141
+ @ended = true
142
+ raise first_error if first_error
143
+ end
144
+ nil
145
+ end
146
+
147
+ def close
148
+ @mutex.synchronize { close_locked(cancel: true) }
149
+ end
150
+
151
+ def closed?
152
+ @mutex.synchronize { @cursor.zero? }
153
+ end
154
+
155
+ private
156
+
157
+ def address(pointer)
158
+ return 0 if pointer.nil?
159
+ pointer.respond_to?(:to_i) ? pointer.to_i : Integer(pointer)
160
+ end
161
+
162
+ def checked_range(offset, length, total, name)
163
+ unless offset >= 0 && length >= 0 && offset <= total && length <= total - offset
164
+ raise RuntimeError, "invalid native #{name} arena range"
165
+ end
166
+ offset...(offset + length)
167
+ end
168
+
169
+ def copy_entry(index)
170
+ raise RuntimeError, "invalid native entry descriptor index" unless index.between?(0, @batch.entry_count - 1)
171
+ # `entries` collides with Fiddle::CStruct#entries; indexed field access
172
+ # selects the actual pointer member.
173
+ entries_address = address(@batch["entries"])
174
+ raise RuntimeError, "native entry descriptor array is null" if entries_address.zero?
175
+ descriptor = Native::DictionaryEntry.new(
176
+ Fiddle::Pointer.new(entries_address + index * Native::DictionaryEntry.size)
177
+ )
178
+ range = checked_range(descriptor.unit_offset, descriptor.unit_len, @batch.unit_count, "unit")
179
+ unit_address = address(@batch.units)
180
+ raise RuntimeError, "native unit arena is null" if range.size.positive? && unit_address.zero?
181
+ key = case @info.domain
182
+ when BYTE
183
+ range.size.zero? ? "".b : Fiddle::Pointer.new(unit_address + range.begin)[0, range.size].b
184
+ when UNICODE_SCALAR
185
+ scalars = range.size.zero? ? [] : Fiddle::Pointer.new(unit_address + range.begin * 4)[0, range.size * 4].unpack("L*")
186
+ scalars.pack("U*")
187
+ when U64
188
+ range.size.zero? ? [] : Fiddle::Pointer.new(unit_address + range.begin * 8)[0, range.size * 8].unpack("Q*")
189
+ else
190
+ raise RuntimeError, "unknown native entry unit domain #{@info.domain}"
191
+ end
192
+ value = case descriptor.value_len
193
+ when 0
194
+ nil
195
+ when 1
196
+ value_range = checked_range(descriptor.value_offset, 1, @batch.value_count, "value")
197
+ values_address = address(@batch.values)
198
+ raise RuntimeError, "native value arena is null" if values_address.zero?
199
+ Fiddle::Pointer.new(values_address + value_range.begin * 8)[0, 8].unpack1("Q")
200
+ else
201
+ raise RuntimeError, "invalid native optional-u64 descriptor"
202
+ end
203
+ Entry.new(key, value, @info.domain)
204
+ end
205
+
206
+ def release_locked
207
+ return nil unless @leased
208
+ Libdictenstein.check(Native.ldict_entry_cursor_release(@cursor, @batch.generation))
209
+ @leased = false
210
+ @index = 0
211
+ nil
212
+ end
213
+
214
+ def native_error(status)
215
+ status.zero? ? nil : Error.new(status)
216
+ end
217
+
218
+ def close_locked(cancel:)
219
+ return nil if @cursor.zero?
220
+ first_error = cancel ? native_error(Native.ldict_entry_cursor_cancel(@cursor)) : nil
221
+ begin
222
+ release_locked
223
+ rescue Exception => error
224
+ first_error ||= error
225
+ end
226
+ free_error = native_error(Native.ldict_entry_cursor_free(@cursor))
227
+ if free_error.nil?
228
+ @cursor = 0
229
+ @ended = true
230
+ else
231
+ first_error ||= free_error
232
+ end
233
+ raise first_error if first_error
234
+ nil
235
+ end
236
+ end
237
+
238
+ # Public bounded stream. Enumerable#each uses ensure so break and raised
239
+ # exceptions deterministically close the native cursor.
240
+ class EntryStream
241
+ include Enumerable
242
+
243
+ attr_reader :info
244
+
245
+ def initialize(handle, max_entries:, max_units:, max_values:)
246
+ @state = EntryCursorState.new(
247
+ handle,
248
+ max_entries: max_entries,
249
+ max_units: max_units,
250
+ max_values: max_values
251
+ )
252
+ @info = @state.info
253
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@state))
254
+ end
255
+
256
+ def self.finalizer(state) = proc { state.close rescue nil }
257
+
258
+ def next = @state.next_entry
259
+
260
+ def each
261
+ return enum_for(__method__) unless block_given?
262
+ begin
263
+ while (entry = self.next)
264
+ yield entry
265
+ end
266
+ ensure
267
+ close
268
+ end
269
+ self
270
+ end
271
+
272
+ def cancel = @state.cancel
273
+
274
+ def close
275
+ @state.close
276
+ ObjectSpace.undefine_finalizer(self) if @state.closed?
277
+ nil
278
+ end
279
+ end
280
+
281
+ class Dictionary
282
+ include Enumerable
283
+
284
+ attr_reader :handle
285
+ def initialize(pointer)
286
+ @handle = ConcurrentHandle.new(pointer)
287
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@handle))
288
+ end
289
+ def self.finalizer(handle) = proc { handle.close rescue nil }
290
+
291
+ def close
292
+ @handle.close
293
+ ObjectSpace.undefine_finalizer(self)
294
+ nil
295
+ end
296
+
297
+ def with_resource
298
+ @handle.with_pointer do |pointer|
299
+ resource = Native::Resource.malloc
300
+ Libdictenstein.check(Native.ldict_dictionary_resource(pointer, resource))
301
+ yield resource.context.to_i, resource.vtable.to_i
302
+ end
303
+ end
304
+
305
+ def kind = scalar(:ldict_dictionary_kind, Native.u64_output, ->(output) { Native.read_u64(output) & 0xffff_ffff })
306
+ def capabilities = scalar(:ldict_dictionary_capabilities, Native.u64_output, Native.method(:read_u64))
307
+ def length = scalar(:ldict_dictionary_len, Native.size_output, Native.method(:read_size))
308
+ alias size length
309
+
310
+ def entry_stream(max_entries: 256, max_units: 4096, max_values: 256)
311
+ EntryStream.new(
312
+ @handle,
313
+ max_entries: max_entries,
314
+ max_units: max_units,
315
+ max_values: max_values
316
+ )
317
+ end
318
+
319
+ def each
320
+ stream = entry_stream
321
+ return stream.each unless block_given?
322
+ stream.each { |entry| yield entry }
323
+ self
324
+ end
325
+
326
+ # Host-owned materialized snapshot idioms.
327
+ def entries = each.to_a
328
+ def keys = entries.map(&:key)
329
+ def values = entries.map(&:value)
330
+
331
+ def include?(term)
332
+ output = Native.byte_output
333
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_contains_text(pointer, term.b, term.bytesize, output)) }
334
+ output[0].positive?
335
+ end
336
+
337
+ def get(term)
338
+ found, value, present = Native.byte_output, Native.u64_output, Native.byte_output
339
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_get_text_value(pointer, term.b, term.bytesize, found, value, present)) }
340
+ Lookup.new(found[0].positive?, present[0].positive? ? Native.read_u64(value) : nil)
341
+ end
342
+
343
+ def include_u64?(tokens)
344
+ packed = tokens.pack("Q*"); output = Native.byte_output
345
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_contains_u64(pointer, packed, tokens.length, output)) }
346
+ output[0].positive?
347
+ end
348
+
349
+ def get_u64(tokens)
350
+ packed = tokens.pack("Q*"); found, value, present = Native.byte_output, Native.u64_output, Native.byte_output
351
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_get_u64_value(pointer, packed, tokens.length, found, value, present)) }
352
+ Lookup.new(found[0].positive?, present[0].positive? ? Native.read_u64(value) : nil)
353
+ end
354
+
355
+ private
356
+
357
+ def scalar(function, output, decode)
358
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.public_send(function, pointer, output)) }
359
+ decode.call(output)
360
+ end
361
+
362
+ def put_text(term, value)
363
+ output = Native.byte_output
364
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_insert_text_value(pointer, term.b, term.bytesize, value || 0, value.nil? ? 0 : 1, output)) }
365
+ output[0].positive?
366
+ end
367
+
368
+ def remove_text(term)
369
+ output = Native.byte_output
370
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_remove_text(pointer, term.b, term.bytesize, output)) }
371
+ output[0].positive?
372
+ end
373
+
374
+ def put_tokens(tokens, value)
375
+ packed = tokens.pack("Q*"); output = Native.byte_output
376
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_insert_u64_value(pointer, packed, tokens.length, value || 0, value.nil? ? 0 : 1, output)) }
377
+ output[0].positive?
378
+ end
379
+
380
+ def remove_tokens(tokens)
381
+ packed = tokens.pack("Q*"); output = Native.byte_output
382
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_remove_u64(pointer, packed, tokens.length, output)) }
383
+ output[0].positive?
384
+ end
385
+ end
386
+
387
+ class DynamicDawg < Dictionary
388
+ def initialize(domain: UNICODE_SCALAR)
389
+ output = Native.pointer_output; Libdictenstein.check(Native.ldict_dynamic_dawg_new(domain, output)); super(Native.read_pointer(output))
390
+ end
391
+ def put(term, value = nil) = put_text(term, value)
392
+ def remove(term) = remove_text(term)
393
+ def put_u64(tokens, value = nil) = put_tokens(tokens, value)
394
+ def remove_u64(tokens) = remove_tokens(tokens)
395
+ def clear = @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_clear(pointer)) }
396
+ def compact
397
+ output = Native.size_output; @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_compact(pointer, output)) }; Native.read_size(output)
398
+ end
399
+ def put_all(entries)
400
+ terms = entries.map { |term, _value| term.b }
401
+ memory = Fiddle::Pointer.malloc([1, entries.length * Native::TextEntry.size].max, Fiddle::RUBY_FREE)
402
+ entries.each_with_index do |(_term, value), index|
403
+ item = Native::TextEntry.new(memory + index * Native::TextEntry.size)
404
+ item.data = Fiddle::Pointer[terms[index]]
405
+ item.len = terms[index].bytesize
406
+ item.value = value || 0
407
+ item.has_value = value.nil? ? 0 : 1
408
+ end
409
+ output = Native.size_output
410
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_insert_text_batch(pointer, memory, entries.length, output)) }
411
+ Native.read_size(output)
412
+ end
413
+ end
414
+
415
+ class DoubleArrayTrie < Dictionary
416
+ def initialize(entries, domain: UNICODE_SCALAR)
417
+ terms = entries.map { |term, _value| term.b }
418
+ memory = Fiddle::Pointer.malloc([1, entries.length * Native::TextEntry.size].max, Fiddle::RUBY_FREE)
419
+ entries.each_with_index do |(_term, value), index|
420
+ item = Native::TextEntry.new(memory + index * Native::TextEntry.size)
421
+ item.data = Fiddle::Pointer[terms[index]]; item.len = terms[index].bytesize; item.value = value || 0; item.has_value = value.nil? ? 0 : 1
422
+ end
423
+ output = Native.pointer_output; Libdictenstein.check(Native.ldict_double_array_trie_new(domain, memory, entries.length, output)); super(Native.read_pointer(output))
424
+ end
425
+ end
426
+
427
+ class Scdawg < Dictionary
428
+ def initialize(domain: UNICODE_SCALAR)
429
+ output = Native.pointer_output; Libdictenstein.check(Native.ldict_scdawg_new(domain, output)); super(Native.read_pointer(output))
430
+ end
431
+ def put(term, value = nil) = put_text(term, value)
432
+ def include_substring?(term)
433
+ output = Native.byte_output; @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_scdawg_contains_substring(pointer, term.b, term.bytesize, output)) }; output[0].positive?
434
+ end
435
+ def substring_frequency(term)
436
+ output = Native.size_output; @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_scdawg_substring_frequency(pointer, term.b, term.bytesize, output)) }; Native.read_size(output)
437
+ end
438
+ end
439
+
440
+ class PersistentArtrie < Dictionary
441
+ def self.create(path, domain: UNICODE_SCALAR) = open_native(path, domain, true)
442
+ def self.open(path, domain: UNICODE_SCALAR) = open_native(path, domain, false)
443
+ def self.open_native(path, domain, create)
444
+ text = File.expand_path(path).b; output = Native.pointer_output
445
+ function = create ? :ldict_persistent_artrie_create : :ldict_persistent_artrie_open
446
+ Libdictenstein.check(Native.public_send(function, domain, text, text.bytesize, output)); new(Native.read_pointer(output))
447
+ end
448
+ def put(term, value = nil) = put_text(term, value)
449
+ def remove(term) = remove_text(term)
450
+ def put_u64(tokens, value = nil) = put_tokens(tokens, value)
451
+ def remove_u64(tokens) = remove_tokens(tokens)
452
+ def checkpoint = @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_checkpoint(pointer)) }
453
+ end
454
+
455
+ class PersistentVocabulary < Dictionary
456
+ def self.create(path) = open_native(path, true)
457
+ def self.open(path) = open_native(path, false)
458
+ def self.open_native(path, create)
459
+ text = File.expand_path(path).b; output = Native.pointer_output
460
+ function = create ? :ldict_persistent_vocab_create : :ldict_persistent_vocab_open
461
+ Libdictenstein.check(Native.public_send(function, text, text.bytesize, output)); new(Native.read_pointer(output))
462
+ end
463
+ def put(term, index) = put_text(term, index)
464
+ def term(index)
465
+ length, found = Native.size_output, Native.byte_output
466
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_vocab_get_term(pointer, index, 0, 0, length, found)) }
467
+ return nil if found[0].zero?
468
+ output = Fiddle::Pointer.malloc(Native.read_size(length), Fiddle::RUBY_FREE)
469
+ @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_vocab_get_term(pointer, index, output, output.size, length, found)) }
470
+ output[0, Native.read_size(length)].force_encoding(Encoding::UTF_8)
471
+ end
472
+ def checkpoint = @handle.with_pointer { |pointer| Libdictenstein.check(Native.ldict_dictionary_checkpoint(pointer)) }
473
+ end
474
+ end
475
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libdictenstein
3
+ version: !ruby/object:Gem::Version
4
+ version: 4.0.0.rc.4
5
+ platform: ruby
6
+ authors:
7
+ - Dylon Edwards
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: DynamicDAWG, DAT, SCDAWG, and persistent ARTrie bindings for Ruby
13
+ email:
14
+ - dylon.devo@gmail.com
15
+ executables:
16
+ - libdictenstein-collection-profile
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - bin/libdictenstein-collection-profile
22
+ - lib/vinary_tree/libdictenstein.rb
23
+ - lib/vinary_tree/libdictenstein/native.rb
24
+ - lib/vinary_tree/libdictenstein/native/darwin-arm64/liblibdictenstein.dylib
25
+ - lib/vinary_tree/libdictenstein/native/linux-arm64/liblibdictenstein.so
26
+ - lib/vinary_tree/libdictenstein/native/linux-x64/liblibdictenstein.so
27
+ - lib/vinary_tree/libdictenstein/native/windows-x64/libdictenstein.dll
28
+ - lib/vinary_tree/libdictenstein/version.rb
29
+ homepage: https://github.com/vinary-tree/libdictenstein
30
+ licenses:
31
+ - Apache-2.0
32
+ metadata:
33
+ source_code_uri: https://github.com/vinary-tree/libdictenstein
34
+ rubygems_mfa_required: 'true'
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.3'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.7.0.dev
50
+ specification_version: 4
51
+ summary: High-performance modular dictionary backends
52
+ test_files: []