spellkit 0.3.0 → 1.0.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: a16cdfa3277c6161351b4b9f2ba35720dd78487f7e7b49f6c3cebc6a6a4d2d4b
4
- data.tar.gz: 71ec217bee6087915e2fa2fec3229ba4fa1ee8b354c437acf68ef5abcef19b0f
3
+ metadata.gz: 529c36dc34cc7b87f8942db94f4df9c33831d7e9adac6d78c6205b96a5c08d20
4
+ data.tar.gz: 25bb9acef445ba184adeccb94da81252c78ccff57f654e18b37b26c85053a0bf
5
5
  SHA512:
6
- metadata.gz: 47903715b908a7d5e55837dc94edacf50f91399a74b2ec12174c22015657864624436cc59b5d9ec93de349d1dd17da45edda3f879a8622c320190568335d4f85
7
- data.tar.gz: 0ef569d807b305ff9c3f1598e0c705ae72559aefd3efd644c90dc8f749db389d05861a698b39dd9ead5fe95652b5350e7519c25e7b17fc622422d91900cb43f9
6
+ metadata.gz: f5c337f5032e5e700c4fbd96f490a99996f7f7dff394187424e231f6ec96b5c69c66d9d9a98f52a39589acadb8dbf3cea24cd988554ba39bd8b44c5c56bc0ead
7
+ data.tar.gz: 65f2840f521fd8f870e14b082b66e7aba65c1b0b6520637018f9de0205d706f31985d40443ae23d24791839bd53b731558603342b03489571b07add6080e5e30
data/README.md CHANGED
@@ -240,9 +240,91 @@ MyBrand
240
240
  SpecialTerm
241
241
  ```
242
242
 
243
+ ## Dictionary Packs
244
+
245
+ SpellKit still bundles no dictionaries. A **pack** is a separate gem that carries the data
246
+ and registers itself when it loads, so you choose packs in your Gemfile:
247
+
248
+ ```ruby
249
+ gem "spellkit"
250
+ gem "spellkit-general-medical" # drug, condition and gene vocabulary + general English
251
+ ```
252
+
253
+ ```ruby
254
+ SpellKit.enable_dictionary(:general_medical, lazy: true)
255
+ SpellKit.correct("acetaminphen") # => "acetaminophen"
256
+ ```
257
+
258
+ A pack ships the tuning that was measured against its own data, so you don't have to
259
+ rediscover `edit_distance` and `frequency_threshold` yourself. Override anything:
260
+
261
+ ```ruby
262
+ SpellKit.enable_dictionary(:general_medical, edit_distance: 1)
263
+ ```
264
+
265
+ SpellKit knows the name of no pack — it provides only the mechanism, so a pack can ship
266
+ new terms or new tuning without SpellKit releasing anything.
267
+
268
+ ### If you load this from a Rails initializer, read this
269
+
270
+ **An initializer runs in every process that boots the app** — web, `rails db:migrate`,
271
+ `rake`, `console`, sidecars — not just the one that searches. Loading a pack eagerly makes
272
+ all of them pay, and a ~200k-term pack at `edit_distance: 2` measures **~2.1 GB resident
273
+ and ~5.5s**. That has already OOM-killed a memory-constrained migrate init container in
274
+ production.
275
+
276
+ Pass `lazy: true`. Registration then costs nothing and the index is built on first real
277
+ use, which a migrate or rake process never reaches:
278
+
279
+ | | time | RSS |
280
+ |---|---|---|
281
+ | `enable_dictionary(..., lazy: true)` | 0.000s | +0 MB |
282
+ | first `correct()` after that | ~5.5s | +2,076 MB |
283
+ | every later call | ~0.0001s | +0 MB |
284
+
285
+ **Lazy moves the cost, it doesn't remove it.** For a web server you usually want the
286
+ server to pay it rather than the first user, so warm it at worker boot:
287
+
288
+ ```ruby
289
+ # config/puma.rb
290
+ on_worker_boot { SpellKit.load_dictionary! }
291
+ ```
292
+
293
+ `stats` and `healthcheck` deliberately **do not** trigger the load — they report
294
+ `{"loaded" => false, "deferred" => true}`. A liveness probe must not be able to build the
295
+ index in the very process `lazy` protects.
296
+
297
+ ```ruby
298
+ SpellKit.dictionary_loaded? # false until something forces the load
299
+ SpellKit.load_dictionary! # force it now; idempotent, no-op when eager
300
+ ```
301
+
302
+ Memory scales steeply with `edit_distance`: the same pack is **484 MB at 1** versus
303
+ **2.1 GB at 2**, because SymSpell's deletion index grows sharply with distance. If you are
304
+ memory-constrained, that knob matters more than lazy loading does.
305
+
306
+ ### Writing a pack gem
307
+
308
+ A pack gem ships its data and one registration call:
309
+
310
+ ```ruby
311
+ # lib/spellkit-my-domain.rb
312
+ require "spellkit"
313
+
314
+ SpellKit::Packs.register(:my_domain,
315
+ dictionary: File.expand_path("../data/dictionary.tsv", __dir__),
316
+ protected_path: File.expand_path("../data/protected.txt", __dir__),
317
+ defaults: {edit_distance: 1, frequency_threshold: 10.0},
318
+ summary: "What this pack covers")
319
+ ```
320
+
321
+ Bundler requires it automatically, so `SpellKit.enable_dictionary(:my_domain)` then works.
322
+ Registration verifies the files exist, so a packaging mistake fails while the stack still
323
+ points at your gem.
324
+
243
325
  ## Dictionary Sources
244
326
 
245
- SpellKit doesn't bundle dictionaries, but works with several sources:
327
+ SpellKit works with several raw dictionary sources directly:
246
328
 
247
329
  ### Use the Default Dictionary (Recommended)
248
330
  ```ruby
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpellKit
4
+ # A checker that has not built its index yet.
5
+ #
6
+ # WHY. A Rails initializer runs in EVERY process that boots the app - web, db:migrate,
7
+ # rake, console, sidecars - but usually only the web server ever spell-checks anything.
8
+ # Loading eagerly makes all of them pay, and the cost is not small: a ~200k-term pack at
9
+ # edit_distance 2 measures ~2.1 GB resident and several seconds to index. That is not
10
+ # hypothetical - it OOM-killed a memory-constrained migrate init container in production.
11
+ #
12
+ # WHAT DOES NOT TRIGGER A LOAD. `stats` and `healthcheck` deliberately do not, and that
13
+ # matters more than it first appears: a liveness probe hitting a health endpoint would
14
+ # otherwise build the whole index in exactly the process this class exists to protect,
15
+ # silently reintroducing the bug. They report the deferred state instead.
16
+ #
17
+ # THREAD SAFETY. A threaded server can take two concurrent first requests; without the
18
+ # mutex both would build a multi-gigabyte index. The build happens at most once.
19
+ class LazyChecker
20
+ # Lookups need a real index. Introspection must not build one.
21
+ FORCES_LOAD = %i[correct correct? suggestions correct_tokens].freeze
22
+
23
+ attr_reader :pack_name
24
+
25
+ def initialize(pack_name, options)
26
+ @pack_name = pack_name
27
+ @options = options
28
+ @mutex = Mutex.new
29
+ @checker = nil
30
+ end
31
+
32
+ def loaded?
33
+ !@checker.nil?
34
+ end
35
+
36
+ # Build the index now. Idempotent and thread-safe.
37
+ #
38
+ # Lazy loading MOVES the cost rather than removing it, so for a web process you
39
+ # usually want the server to pay it instead of the first user:
40
+ #
41
+ # # config/puma.rb
42
+ # on_worker_boot { SpellKit.load_dictionary! }
43
+ def load_now!
44
+ return @checker if @checker
45
+
46
+ @mutex.synchronize do
47
+ # Re-check inside the lock; another thread may have loaded while we waited.
48
+ @checker ||= Checker.new.load!(**resolved_options)
49
+ end
50
+ end
51
+
52
+ FORCES_LOAD.each do |name|
53
+ define_method(name) { |*args| load_now!.public_send(name, *args) }
54
+ end
55
+
56
+ def stats
57
+ loaded? ? @checker.stats : deferred_report
58
+ end
59
+
60
+ def healthcheck
61
+ loaded? ? @checker.healthcheck : deferred_report
62
+ end
63
+
64
+ def method_missing(name, *args, &block)
65
+ if Checker.method_defined?(name)
66
+ load_now!.public_send(name, *args, &block)
67
+ else
68
+ super
69
+ end
70
+ end
71
+
72
+ def respond_to_missing?(name, include_private = false)
73
+ Checker.method_defined?(name) || super
74
+ end
75
+
76
+ private
77
+
78
+ # Resolved here rather than through a module-level helper so that helper can stay
79
+ # private: 1.0.0 freezes the public surface, and an internal seam that only exists to
80
+ # let one collaborator reach a private method should not be part of it.
81
+ def resolved_options
82
+ return @options if @pack_name.nil?
83
+
84
+ Packs.fetch(@pack_name).load_options(**@options)
85
+ end
86
+
87
+ def deferred_report
88
+ {"loaded" => false, "deferred" => true, "pack" => @pack_name&.to_s}
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpellKit
4
+ # A dictionary pack that some other gem has registered.
5
+ #
6
+ # SpellKit ships NO packs and knows the name of none. It provides the mechanism; a
7
+ # separate data gem (spellkit-general-medical, say) requires itself, calls
8
+ # SpellKit::Packs.register, and supplies both the files and the tuning that was measured
9
+ # against them. That keeps the "SpellKit doesn't bundle dictionaries" promise in the
10
+ # README literally true while still letting `SpellKit.enable_dictionary(:some_pack)`
11
+ # work, and it means a pack can ship a new version - new terms, new tuning - without
12
+ # SpellKit releasing anything at all.
13
+ Pack = Struct.new(:name, :dictionary, :protected_path, :defaults, :summary, keyword_init: true) do
14
+ # The keyword arguments to hand to load!, with caller overrides applied last.
15
+ def load_options(**overrides)
16
+ options = {dictionary: dictionary.to_s}
17
+ options[:protected_path] = protected_path.to_s if protected_path
18
+ defaults.merge(options).merge(overrides)
19
+ end
20
+ end
21
+
22
+ # The registry of packs some installed gem has announced.
23
+ module Packs
24
+ class << self
25
+ def registry
26
+ @registry ||= {}
27
+ end
28
+
29
+ # Called by a data gem when it loads.
30
+ #
31
+ # SpellKit::Packs.register(:general_medical,
32
+ # dictionary: "#{__dir__}/../data/dictionary.tsv",
33
+ # protected_path: "#{__dir__}/../data/protected.txt",
34
+ # defaults: {edit_distance: 2, frequency_threshold: 1.0})
35
+ #
36
+ # `defaults` is how a pack ships the tuning that was measured against ITS data,
37
+ # rather than leaving every consumer to rediscover it.
38
+ def register(name, dictionary:, protected_path: nil, defaults: {}, summary: nil)
39
+ key = normalize(name)
40
+
41
+ # Checked here, at require time, rather than on first use: a data gem whose files
42
+ # did not survive packaging is broken, and saying so while the stack still points
43
+ # at that gem beats an inexplicable failure during someone's first search. This is
44
+ # a stat, so it costs nothing and does not defeat lazy loading, which exists to
45
+ # defer the index BUILD.
46
+ unless File.exist?(dictionary.to_s)
47
+ raise FileNotFoundError,
48
+ "Pack #{key.inspect} registered a dictionary that does not exist: #{dictionary}"
49
+ end
50
+
51
+ if protected_path && !File.exist?(protected_path.to_s)
52
+ raise FileNotFoundError,
53
+ "Pack #{key.inspect} registered a protected-terms file that does not exist: #{protected_path}"
54
+ end
55
+
56
+ registry[key] = Pack.new(
57
+ name: key, dictionary: dictionary, protected_path: protected_path,
58
+ defaults: symbolize(defaults), summary: summary
59
+ )
60
+ end
61
+
62
+ def registered?(name)
63
+ registry.key?(normalize(name))
64
+ end
65
+
66
+ # [:general_medical, ...]
67
+ def names
68
+ registry.keys.map(&:to_sym)
69
+ end
70
+
71
+ def fetch(name)
72
+ registry.fetch(normalize(name)) do
73
+ raise UnknownPackError, unknown_message(name)
74
+ end
75
+ end
76
+
77
+ # Test hook.
78
+ def reset!
79
+ @registry = {}
80
+ end
81
+
82
+ # :general_medical, "general_medical" and "general-medical" all address one pack.
83
+ def normalize(name)
84
+ name.to_s.strip.downcase.tr("-", "_")
85
+ end
86
+
87
+ private
88
+
89
+ def unknown_message(name)
90
+ if registry.empty?
91
+ "No dictionary packs are registered. A pack lives in its own gem - add one to " \
92
+ "your Gemfile (e.g. gem \"spellkit-general-medical\") and it registers itself " \
93
+ "when it loads. To use your own files instead, pass them directly: " \
94
+ "SpellKit.enable_dictionary(dictionary: \"path/to.tsv\")."
95
+ else
96
+ "Unknown dictionary pack #{name.inspect}. Registered: #{names.inspect}. A pack " \
97
+ "registers itself when its gem loads, so a missing one usually means the gem is " \
98
+ "not in your Gemfile."
99
+ end
100
+ end
101
+
102
+ def symbolize(hash)
103
+ (hash || {}).each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpellKit
4
- VERSION = "0.3.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/spellkit.rb CHANGED
@@ -17,6 +17,9 @@ rescue LoadError
17
17
  require "spellkit/spellkit"
18
18
  end
19
19
 
20
+ require_relative "spellkit/packs"
21
+ require_relative "spellkit/lazy_checker"
22
+
20
23
  module SpellKit
21
24
  class Error < StandardError; end
22
25
  class NotLoadedError < Error; end
@@ -24,6 +27,10 @@ module SpellKit
24
27
  class InvalidArgumentError < Error; end
25
28
  class DownloadError < Error; end
26
29
 
30
+ # Raised when a pack name is not registered - almost always a data gem missing from
31
+ # the Gemfile, since a pack registers itself when its gem loads.
32
+ class UnknownPackError < Error; end
33
+
27
34
  # Default dictionary: SymSpell English 80k frequency dictionary
28
35
  DEFAULT_DICTIONARY_URL = "https://raw.githubusercontent.com/wolfgarbe/SymSpell/master/SymSpell.FrequencyDictionary/en-80k.txt"
29
36
 
@@ -98,6 +105,83 @@ module SpellKit
98
105
  def healthcheck
99
106
  default.healthcheck
100
107
  end
108
+
109
+ # ----------------------------------------------------------------------------------
110
+ # Dictionary packs
111
+ #
112
+ # SpellKit still bundles no dictionaries. A pack lives in its own gem, registers
113
+ # itself on load (see SpellKit::Packs), and brings the tuning that was measured
114
+ # against its own data. These methods are the mechanism only - SpellKit knows the
115
+ # name of no pack.
116
+ # ----------------------------------------------------------------------------------
117
+
118
+ # Configure the DEFAULT checker from a registered pack, so plain SpellKit.correct
119
+ # becomes domain-aware.
120
+ #
121
+ # SpellKit.enable_dictionary(:general_medical)
122
+ # SpellKit.enable_dictionary(:general_medical, lazy: true) # defer the index build
123
+ # SpellKit.enable_dictionary(:general_medical, edit_distance: 1)
124
+ # SpellKit.enable_dictionary(dictionary: "my.tsv") # your own files
125
+ #
126
+ # `lazy: true` is strongly recommended from a Rails initializer - see LazyChecker for
127
+ # why. Eager stays the default so this is non-breaking.
128
+ def enable_dictionary(pack = nil, lazy: false, **options)
129
+ # Resolve eagerly even when lazy, so an unregistered pack (usually a data gem
130
+ # missing from the Gemfile) fails at boot rather than on a user's first search.
131
+ @dictionary_pack = pack.nil? ? nil : Packs.fetch(pack)
132
+
133
+ self.default = if lazy
134
+ LazyChecker.new(pack, options)
135
+ else
136
+ load!(**pack_load_options(pack, **options))
137
+ end
138
+ end
139
+
140
+ # An INDEPENDENT checker, for running more than one pack at once. Does not touch the
141
+ # default checker.
142
+ def dictionary_checker(pack = nil, lazy: false, **options)
143
+ Packs.fetch(pack) unless pack.nil?
144
+ return LazyChecker.new(pack, options) if lazy
145
+
146
+ Checker.new.load!(**pack_load_options(pack, **options))
147
+ end
148
+
149
+ # True once the default checker holds a real index.
150
+ def dictionary_loaded?
151
+ checker = @default
152
+ return false if checker.nil?
153
+ return checker.loaded? if checker.is_a?(LazyChecker)
154
+
155
+ true
156
+ end
157
+
158
+ # Force a deferred load now; no-op when already loaded or configured eagerly.
159
+ def load_dictionary!
160
+ checker = @default
161
+ checker.load_now! if checker.is_a?(LazyChecker)
162
+ checker
163
+ end
164
+
165
+ # The pack backing the default checker, or nil when configured from raw files.
166
+ attr_reader :dictionary_pack
167
+
168
+ private
169
+
170
+ # Internal: resolve a pack name (or a bare options hash) into load! keyword arguments.
171
+ # Private on purpose - see LazyChecker#resolved_options.
172
+ def pack_load_options(pack = nil, **overrides)
173
+ if pack.nil?
174
+ unless overrides.key?(:dictionary)
175
+ raise InvalidArgumentError,
176
+ "Pass a registered pack name or a dictionary: of your own. " \
177
+ "Registered packs: #{Packs.names.inspect}"
178
+ end
179
+
180
+ return overrides
181
+ end
182
+
183
+ Packs.fetch(pack).load_options(**overrides)
184
+ end
101
185
  end
102
186
  end
103
187
 
@@ -113,10 +197,9 @@ class SpellKit::Checker
113
197
  alias_method :_rust_healthcheck, :healthcheck
114
198
 
115
199
  def load!(dictionary: nil, protected_path: nil, protected_patterns: [],
116
- edit_distance: 1, frequency_threshold: 10.0,
117
- skip_urls: false, skip_emails: false, skip_hostnames: false,
118
- skip_code_patterns: false, skip_numbers: false, **_options)
119
-
200
+ edit_distance: 1, frequency_threshold: 10.0,
201
+ skip_urls: false, skip_emails: false, skip_hostnames: false,
202
+ skip_code_patterns: false, skip_numbers: false, **_options)
120
203
  # Validate dictionary parameter
121
204
  raise SpellKit::InvalidArgumentError, "dictionary parameter is required" if dictionary.nil?
122
205
 
@@ -320,7 +403,7 @@ class SpellKit::Checker
320
403
  raise SpellKit::InvalidArgumentError, "Invalid URL: #{url} (#{e.message})"
321
404
  rescue Timeout::Error => e
322
405
  raise SpellKit::DownloadError, "Download timed out: #{url} (#{e.message})"
323
- rescue StandardError => e
406
+ rescue => e
324
407
  raise SpellKit::DownloadError, "Failed to download dictionary: #{e.message}"
325
408
  end
326
409
 
@@ -356,13 +439,13 @@ class SpellKit::Checker
356
439
  raise SpellKit::DownloadError, "HTTP #{response.code}: #{response.message} (#{url})"
357
440
  end
358
441
  end
359
- rescue Net::OpenTimeout => e
442
+ rescue Net::OpenTimeout
360
443
  raise Timeout::Error, "Connection timeout after #{open_timeout}s: #{url}"
361
- rescue Net::ReadTimeout => e
444
+ rescue Net::ReadTimeout
362
445
  raise Timeout::Error, "Read timeout after #{read_timeout}s: #{url}"
363
446
  rescue SocketError => e
364
447
  raise SpellKit::DownloadError, "Network error: #{e.message} (#{url})"
365
448
  rescue OpenSSL::SSL::SSLError => e
366
449
  raise SpellKit::DownloadError, "SSL verification failed: #{e.message} (#{url})"
367
450
  end
368
- end
451
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spellkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Petersen
@@ -179,6 +179,8 @@ files:
179
179
  - ext/spellkit/src/lib.rs
180
180
  - ext/spellkit/src/symspell.rs
181
181
  - lib/spellkit.rb
182
+ - lib/spellkit/lazy_checker.rb
183
+ - lib/spellkit/packs.rb
182
184
  - lib/spellkit/version.rb
183
185
  homepage: https://github.com/scientist-labs/spellkit
184
186
  licenses: