Skip to content

Dictionary pack mechanism + lazy loading (1.0.0) - #7

Merged
cpetersen merged 2 commits into
mainfrom
pack-registry
Sep 8, 2026
Merged

cpetersen merged 2 commits into
mainfrom
pack-registry

Conversation

@cpetersen

Copy link
Copy Markdown
Member

Adds a pack mechanism to SpellKit. It still bundles no dictionaries and knows the name of none — a pack lives in its own gem and registers itself on load.

gem "spellkit"
gem "spellkit-general-medical"

SpellKit.enable_dictionary(:general_medical, lazy: true)

Why registration, not a registry

A built-in registry would give SpellKit domain knowledge and make every pack version bump require a SpellKit release. Self-registration keeps the README's "SpellKit doesn't bundle dictionaries" promise literally true, and lets a pack ship new terms or new tuning without this gem releasing anything.

Why lazy loading

A Rails initializer runs in every process that boots the app — web, db:migrate, rake, console — while usually only the web server searches. A ~200k-term pack at edit_distance: 2 costs ~2.1 GB resident and ~5.5s to index. That OOM-killed a memory-constrained migrate init container in production.

Eager remains the default, so this is purely additive.

time RSS
enable_dictionary(..., lazy: true) 0.000s +0 MB
first correct() ~5.5s +2,076 MB
later calls ~0.0001s +0 MB

Details worth reviewing

  • stats / healthcheck do not trigger a deferred load — a liveness probe must not build the index in the process lazy protects.
  • An unregistered pack raises at boot, even under lazy, and the message points at the Gemfile.
  • Registration stats the files, so a data gem whose payload didn't survive packaging fails while the stack still points at that gem.
  • The load is mutex-guarded, spec'd by counting builds under 8 concurrent first calls.
  • load_dictionary! warms it explicitly, for on_worker_boot — lazy moves the cost, it doesn't remove it.

Verification

211 examples, 0 failures. Lint left cleaner than found (28 → 17 offenses); the rest is pre-existing in untouched files.

cpetersen and others added 2 commits September 8, 2026 06:56
SpellKit still bundles no dictionaries, and still knows the name of none. What it
gains is the MECHANISM: a pack lives in its own gem, registers itself on load, and
brings the tuning measured against its own data.

    gem "spellkit"
    gem "spellkit-general-medical"

    SpellKit.enable_dictionary(:general_medical, lazy: true)

Registration rather than a built-in registry is the load-bearing choice. A registry
here would give SpellKit domain knowledge and would make every pack version bump
need a SpellKit release. Self-registration keeps the README's "SpellKit doesn't
bundle dictionaries" promise literally true and lets a pack ship new terms or new
tuning without this gem releasing anything.

LAZY LOADING, because a Rails initializer runs in EVERY process that boots the app
- web, db:migrate, rake, console - while usually only the web server searches. A
~200k-term pack at edit_distance 2 costs ~2.1 GB resident and ~5.5s to index, and
that OOM-killed a memory-constrained migrate init container in production. Eager
stays the default, so this is additive.

stats and healthcheck deliberately do NOT trigger a deferred load, reporting
deferred: true instead. Without that a liveness probe on a health endpoint would
build the whole index in exactly the process lazy loading protects, quietly
reintroducing the bug.

An unregistered pack raises at BOOT even under lazy - almost always a data gem
missing from the Gemfile, and the error says so - rather than failing on a user's
first search. Registration also stats the files, so a data gem whose payload did
not survive packaging fails while the stack still points at that gem.

The load is mutex-guarded, spec'd by counting builds under 8 concurrent first
calls: a threaded server can take two simultaneous first requests and would
otherwise build a multi-gigabyte index twice.

load_dictionary! forces a deferred load for a web-server boot hook, since lazy
MOVES the cost onto the first request rather than removing it.

README documents the initializer hazard with measured numbers, how to write a pack
gem, and that edit_distance dominates footprint (484 MB at 1 vs 2.1 GB at 2).

211 examples, 0 failures. Lint left cleaner than found (28 offenses -> 17); the
remainder is pre-existing in files this branch does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiDy8MvBncxzGnnmvDJMtL
1.0.0 freezes the public API, so this is the last moment to decide what is part of
it. pack_load_options existed only so LazyChecker could resolve a pack name; it was
public purely as a side effect of that collaboration, not because a caller wants
it. LazyChecker now resolves through the already-public Packs.fetch(...).load_options
and the helper is private.

Public pack API is therefore exactly: enable_dictionary, dictionary_checker,
load_dictionary!, dictionary_loaded?, dictionary_pack, and Packs.register.

The thread-safety spec now counts Checker#load! calls rather than the helper. load!
IS the expensive operation, so the count cannot drift if the resolution path is
refactored again - which is exactly what just happened to it.

211 examples, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiDy8MvBncxzGnnmvDJMtL
@cpetersen cpetersen changed the title Dictionary pack mechanism + lazy loading (0.4.0) Dictionary pack mechanism + lazy loading (1.0.0) Sep 8, 2026
@cpetersen
cpetersen merged commit c56ce94 into main Sep 8, 2026
2 checks passed
@cpetersen
cpetersen deleted the pack-registry branch September 8, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant