kotoshu 0.3.0 → 0.4.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.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +335 -2
  3. data/CHANGELOG.md +341 -1
  4. data/CLAUDE.md +4 -0
  5. data/README.adoc +120 -0
  6. data/TODO.impl/36-cleanup-regressions-0.3.1.md +117 -0
  7. data/TODO.impl/37-hunspell-correctness-tier2.md +182 -0
  8. data/TODO.impl/38-onnx-semantic-gating.md +155 -0
  9. data/TODO.impl/39-tier3-and-beyond.md +196 -0
  10. data/data/en/grammar/rules.yaml +468 -0
  11. data/exe/kotoshu +1 -1
  12. data/lib/kotoshu/algorithms/lookup.rb +233 -127
  13. data/lib/kotoshu/algorithms/ngram_suggest.rb +117 -20
  14. data/lib/kotoshu/algorithms/permutations.rb +11 -11
  15. data/lib/kotoshu/algorithms/phonet_suggest.rb +20 -25
  16. data/lib/kotoshu/algorithms/suggest.rb +75 -37
  17. data/lib/kotoshu/algorithms.rb +12 -5
  18. data/lib/kotoshu/analyzers/semantic_analyzer.rb +160 -50
  19. data/lib/kotoshu/analyzers.rb +8 -0
  20. data/lib/kotoshu/cache/base_cache.rb +109 -17
  21. data/lib/kotoshu/cache/eviction_policy.rb +66 -0
  22. data/lib/kotoshu/cache/frequency_cache.rb +44 -4
  23. data/lib/kotoshu/cache/language_cache.rb +18 -8
  24. data/lib/kotoshu/cache/lookup_cache.rb +1 -1
  25. data/lib/kotoshu/cache/model_cache.rb +43 -2
  26. data/lib/kotoshu/cache/suggestion_cache.rb +2 -2
  27. data/lib/kotoshu/cache.rb +9 -4
  28. data/lib/kotoshu/cli/batch_reporter.rb +12 -6
  29. data/lib/kotoshu/cli/cache_command.rb +174 -258
  30. data/lib/kotoshu/cli/completions_command.rb +210 -0
  31. data/lib/kotoshu/cli/display_formatter.rb +15 -17
  32. data/lib/kotoshu/cli/interactive_reviewer.rb +3 -8
  33. data/lib/kotoshu/cli/navigation_manager.rb +1 -0
  34. data/lib/kotoshu/cli/personal_command.rb +113 -0
  35. data/lib/kotoshu/cli.rb +23 -15
  36. data/lib/kotoshu/commands/cache_command.rb +3 -6
  37. data/lib/kotoshu/commands/check_command.rb +13 -24
  38. data/lib/kotoshu/commands/model_command.rb +11 -15
  39. data/lib/kotoshu/commands.rb +12 -0
  40. data/lib/kotoshu/components/passthrough_spell_checker.rb +0 -2
  41. data/lib/kotoshu/components/whitespace_tokenizer.rb +2 -4
  42. data/lib/kotoshu/components.rb +13 -0
  43. data/lib/kotoshu/configuration/builder.rb +0 -2
  44. data/lib/kotoshu/configuration/resolver.rb +1 -1
  45. data/lib/kotoshu/configuration.rb +53 -13
  46. data/lib/kotoshu/core/exceptions.rb +24 -3
  47. data/lib/kotoshu/core/indexed_dictionary.rb +2 -5
  48. data/lib/kotoshu/core/models/affix_rule.rb +37 -35
  49. data/lib/kotoshu/core/models/result/document_result.rb +40 -181
  50. data/lib/kotoshu/core/models/result/word_result.rb +57 -147
  51. data/lib/kotoshu/core/models/word.rb +0 -11
  52. data/lib/kotoshu/core/trie/trie.rb +1 -1
  53. data/lib/kotoshu/core.rb +7 -7
  54. data/lib/kotoshu/data/common_words/de.yml +1 -1
  55. data/lib/kotoshu/data/common_words_loader.rb +2 -2
  56. data/lib/kotoshu/data.rb +8 -0
  57. data/lib/kotoshu/data_structures/bloom_filter.rb +8 -2
  58. data/lib/kotoshu/data_structures.rb +8 -0
  59. data/lib/kotoshu/debug_logger.rb +3 -3
  60. data/lib/kotoshu/defaults.rb +0 -2
  61. data/lib/kotoshu/dictionaries/catalog.rb +0 -3
  62. data/lib/kotoshu/dictionaries.rb +8 -0
  63. data/lib/kotoshu/dictionary/base.rb +33 -7
  64. data/lib/kotoshu/dictionary/cspell.rb +38 -44
  65. data/lib/kotoshu/dictionary/custom.rb +3 -5
  66. data/lib/kotoshu/dictionary/hunspell.rb +15 -32
  67. data/lib/kotoshu/dictionary/plain_text.rb +27 -29
  68. data/lib/kotoshu/dictionary/repository.rb +4 -6
  69. data/lib/kotoshu/dictionary/unified.rb +200 -224
  70. data/lib/kotoshu/dictionary/unix_words.rb +5 -7
  71. data/lib/kotoshu/dictionary.rb +15 -0
  72. data/lib/kotoshu/documents/document.rb +73 -192
  73. data/lib/kotoshu/documents/plain_text_document.rb +51 -128
  74. data/lib/kotoshu/documents/source_position.rb +37 -0
  75. data/lib/kotoshu/documents/source_range.rb +76 -0
  76. data/lib/kotoshu/documents/text_node.rb +49 -0
  77. data/lib/kotoshu/documents.rb +177 -0
  78. data/lib/kotoshu/embeddings/embedding_pipeline.rb +218 -222
  79. data/lib/kotoshu/embeddings/lru_cache.rb +226 -215
  80. data/lib/kotoshu/embeddings/onnx_runtime_model.rb +380 -367
  81. data/lib/kotoshu/embeddings/protocol.rb +92 -79
  82. data/lib/kotoshu/embeddings/protocols.rb +20 -14
  83. data/lib/kotoshu/embeddings/registry.rb +167 -165
  84. data/lib/kotoshu/embeddings/search.rb +187 -175
  85. data/lib/kotoshu/embeddings/similarity_engine.rb +236 -206
  86. data/lib/kotoshu/embeddings/similarity_search.rb +0 -3
  87. data/lib/kotoshu/embeddings/vocabulary.rb +238 -213
  88. data/lib/kotoshu/embeddings.rb +25 -35
  89. data/lib/kotoshu/grammar/pattern_matchers/base_matcher.rb +1 -1
  90. data/lib/kotoshu/grammar/pattern_matchers/double_negative_matcher.rb +98 -24
  91. data/lib/kotoshu/grammar/pattern_matchers/phrase_matcher.rb +86 -0
  92. data/lib/kotoshu/grammar/pattern_matchers/possessive_context_matcher.rb +0 -2
  93. data/lib/kotoshu/grammar/pattern_matchers/vowel_sound_matcher.rb +0 -2
  94. data/lib/kotoshu/grammar/rule.rb +3 -5
  95. data/lib/kotoshu/grammar/rule_engine.rb +21 -36
  96. data/lib/kotoshu/grammar/rule_loader.rb +0 -1
  97. data/lib/kotoshu/grammar.rb +11 -8
  98. data/lib/kotoshu/integrity/audit_log.rb +95 -16
  99. data/lib/kotoshu/integrity/manifest.rb +0 -1
  100. data/lib/kotoshu/integrity/net_http.rb +1 -1
  101. data/lib/kotoshu/integrity/rotation_policy.rb +88 -0
  102. data/lib/kotoshu/integrity.rb +1 -0
  103. data/lib/kotoshu/keyboard/layout.rb +1 -0
  104. data/lib/kotoshu/keyboard/layouts/azerty.rb +0 -2
  105. data/lib/kotoshu/keyboard/layouts/dvorak.rb +0 -2
  106. data/lib/kotoshu/keyboard/layouts/jcuken.rb +0 -2
  107. data/lib/kotoshu/keyboard/layouts/qwerty.rb +1 -3
  108. data/lib/kotoshu/keyboard/layouts/qwertz.rb +0 -2
  109. data/lib/kotoshu/keyboard/registry.rb +1 -8
  110. data/lib/kotoshu/keyboard.rb +11 -2
  111. data/lib/kotoshu/language/detector.rb +21 -21
  112. data/lib/kotoshu/language/identifier.rb +5 -7
  113. data/lib/kotoshu/language/languages/base.rb +15 -0
  114. data/lib/kotoshu/language/normalizer/arabic.rb +113 -0
  115. data/lib/kotoshu/language/normalizer/base.rb +8 -10
  116. data/lib/kotoshu/language/normalizer/hebrew.rb +72 -0
  117. data/lib/kotoshu/language/normalizer/persian.rb +36 -0
  118. data/lib/kotoshu/language/registry.rb +64 -2
  119. data/lib/kotoshu/language/segmenter.rb +122 -0
  120. data/lib/kotoshu/language/suika.rb +38 -0
  121. data/lib/kotoshu/language/tokenizer/french_tokenizer.rb +18 -16
  122. data/lib/kotoshu/language/tokenizer/german_tokenizer.rb +1 -1
  123. data/lib/kotoshu/language/tokenizer/japanese_tokenizer.rb +10 -15
  124. data/lib/kotoshu/language/tokenizer/latin_tokenizer.rb +3 -3
  125. data/lib/kotoshu/language/tokenizer/portuguese_tokenizer.rb +1 -1
  126. data/lib/kotoshu/language/tokenizer/russian_tokenizer.rb +2 -4
  127. data/lib/kotoshu/language/tokenizer/spanish_tokenizer.rb +1 -1
  128. data/lib/kotoshu/language.rb +33 -18
  129. data/lib/kotoshu/languages/ar/language.rb +84 -0
  130. data/lib/kotoshu/languages/de/language.rb +39 -31
  131. data/lib/kotoshu/languages/en/language.rb +36 -18
  132. data/lib/kotoshu/languages/es/language.rb +24 -11
  133. data/lib/kotoshu/languages/fa/language.rb +75 -0
  134. data/lib/kotoshu/languages/fr/language.rb +27 -15
  135. data/lib/kotoshu/languages/he/language.rb +83 -0
  136. data/lib/kotoshu/languages/ja/language.rb +10 -11
  137. data/lib/kotoshu/languages/pt/language.rb +22 -10
  138. data/lib/kotoshu/languages/ru/language.rb +23 -11
  139. data/lib/kotoshu/languages.rb +14 -36
  140. data/lib/kotoshu/metrics_collector.rb +2 -2
  141. data/lib/kotoshu/models/context.rb +3 -3
  142. data/lib/kotoshu/models/embedding_model.rb +4 -5
  143. data/lib/kotoshu/models/fasttext_model.rb +1 -9
  144. data/lib/kotoshu/models/nearest_neighbor.rb +6 -5
  145. data/lib/kotoshu/models/onnx_model.rb +5 -7
  146. data/lib/kotoshu/models/semantic_error.rb +55 -16
  147. data/lib/kotoshu/models/word_embedding.rb +4 -14
  148. data/lib/kotoshu/models.rb +27 -0
  149. data/lib/kotoshu/multi_language_checker.rb +124 -0
  150. data/lib/kotoshu/personal_dictionary.rb +24 -13
  151. data/lib/kotoshu/plugins/registry.rb +0 -2
  152. data/lib/kotoshu/plugins.rb +9 -0
  153. data/lib/kotoshu/readers/aff_data.rb +125 -26
  154. data/lib/kotoshu/readers/aff_reader.rb +26 -17
  155. data/lib/kotoshu/readers/condition_checker.rb +54 -71
  156. data/lib/kotoshu/readers/dic_reader.rb +124 -34
  157. data/lib/kotoshu/readers/file_reader.rb +39 -9
  158. data/lib/kotoshu/readers/lookup_builder.rb +347 -48
  159. data/lib/kotoshu/readers/readers.rb +15 -4
  160. data/lib/kotoshu/readers.rb +18 -2
  161. data/lib/kotoshu/resource_manager.rb +16 -14
  162. data/lib/kotoshu/results.rb +8 -0
  163. data/lib/kotoshu/source_registry.rb +16 -8
  164. data/lib/kotoshu/spellchecker.rb +4 -9
  165. data/lib/kotoshu/string_metrics.rb +9 -9
  166. data/lib/kotoshu/suggestions/context.rb +0 -12
  167. data/lib/kotoshu/suggestions/generator.rb +12 -17
  168. data/lib/kotoshu/suggestions/strategies/base_strategy.rb +28 -19
  169. data/lib/kotoshu/suggestions/strategies/composite_strategy.rb +3 -3
  170. data/lib/kotoshu/suggestions/strategies/edit_distance_strategy.rb +79 -67
  171. data/lib/kotoshu/suggestions/strategies/keyboard_proximity_strategy.rb +5 -5
  172. data/lib/kotoshu/suggestions/strategies/ngram_strategy.rb +2 -2
  173. data/lib/kotoshu/suggestions/strategies/phonetic_strategy.rb +1 -1
  174. data/lib/kotoshu/suggestions/strategies/semantic_strategy.rb +20 -28
  175. data/lib/kotoshu/suggestions/strategies/symspell_strategy.rb +6 -11
  176. data/lib/kotoshu/suggestions/suggestion.rb +57 -77
  177. data/lib/kotoshu/suggestions/suggestion_set.rb +10 -9
  178. data/lib/kotoshu/suggestions.rb +24 -0
  179. data/lib/kotoshu/version.rb +1 -1
  180. data/lib/kotoshu.rb +77 -97
  181. data/test_oop.rb +10 -10
  182. metadata +46 -12
  183. data/lib/kotoshu/documents/asciidoc_document.rb +0 -441
  184. data/lib/kotoshu/documents/location.rb +0 -139
  185. data/lib/kotoshu/documents/markdown_document.rb +0 -389
data/CHANGELOG.md CHANGED
@@ -5,7 +5,347 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.5.0] — 2026-06-30
9
+
10
+ Tier 2.5 release. Adds the personal-dictionary CLI, completes direct
11
+ spec coverage for every previously-untested namespace (~370 new
12
+ examples), eliminates every `respond_to?` / `send`-to-private /
13
+ `instance_variable_*` violation from `lib/`, and fixes a long list of
14
+ production bugs the refactors surfaced.
15
+
16
+ ### Added
17
+
18
+ - **Cache eviction** (`Kotoshu::Cache::EvictionPolicy` +
19
+ `BaseCache#evict` + `kotoshu cache evict`). A pure value object
20
+ decides which entries to evict (LRU by `cached_at`, oldest first)
21
+ to fit a configured size cap; `BaseCache#evict` collects on-disk
22
+ entries (one record per discovered `metadata.json`) and executes
23
+ the plan. `--dry-run` returns the plan without touching disk. The
24
+ cap defaults to `max_cache_size` (1 GB, `KOTOSHU_MAX_CACHE_SIZE`)
25
+ and is now wired through `Configuration` instead of a hardcoded
26
+ constant. Corrupt metadata (missing `cached_at`) sorts oldest so it
27
+ is evicted first.
28
+ - **Audit log rotation** (`Kotoshu::Integrity::RotationPolicy`). When the
29
+ current `audit.log` exceeds `audit_max_bytes` (default 10 MB,
30
+ `KOTOSHU_AUDIT_MAX_BYTES`), the log rotates through `audit_rotations`
31
+ historical files (default 5, `KOTOSHU_AUDIT_ROTATIONS`). Total on-disk
32
+ footprint is bounded at `max_bytes * (rotations + 1)`. The rotation
33
+ policy is a pure value object — `AuditLog#record` consults it on every
34
+ write and executes the returned rename plan under an exclusive flock
35
+ on a sibling lockfile (`audit.log.lock`) so concurrent writers cannot
36
+ race the rename chain. `AuditLog#entries` now walks both the current
37
+ log and all rotations, newest-first.
38
+ - **Shell completion** (`kotoshu completions bash|zsh|fish`). Emits a
39
+ shell-specific completion script that completes top-level subcommands
40
+ and dynamically completes language codes for `setup` / `fetch` by
41
+ shelling out to `kotoshu completions languages`. README documents
42
+ install paths for bash, zsh, and fish.
43
+ - **Load-time model cache integrity** (`Cache::ModelCache`). A cached
44
+ ONNX model file is re-verified against the SHA-256 recorded in its
45
+ `metadata.json` every time it is loaded — not just at download time.
46
+ Tampering, truncation, or partial writes are caught before the file
47
+ reaches the ONNX runtime, and the resulting `Kotoshu::IntegrityError`
48
+ includes a `remediation:` hint pointing the user at
49
+ `kotoshu cache download :LANG --model`. Legacy caches whose metadata
50
+ predates the checksum field continue to load (graceful degradation).
51
+ - **`IntegrityError#remediation`** — the error now accepts an optional
52
+ `remediation:` keyword whose text is appended to the message and
53
+ exposed via a reader, so callers can surface the user-facing fix
54
+ (e.g. "Run `kotoshu cache download :en --model` to re-download").
55
+ Existing callers that omit it see no change in the message format.
56
+ - **Personal dictionary CLI** (`kotoshu personal SUBCOMMAND`). Thor
57
+ subcommand wrapping `Kotoshu::PersonalDictionary`: `add`, `remove`,
58
+ `list`, `import` (with `--dry-run`), `path`, and `clear` (with
59
+ `--yes`). Errors raise `Kotoshu::Cli::Errors::UsageError` (exit 2),
60
+ matching the existing CLI error contract.
61
+ - **Direct specs for previously-untested namespaces.** ~370 new
62
+ examples pinning the public contract of `Integrity`, `Embeddings`,
63
+ `Language`, `Dictionary`, and `Cache` namespaces — each spec uses
64
+ real instances and tmpdir/fixture IO, never `double()`.
65
+ - **`Kotoshu::Dictionary::Unified`** is now loadable. The class
66
+ was previously declared as `class Kotoshu::Dictionary`, colliding
67
+ with the existing `module Kotoshu::Dictionary` namespace and
68
+ raising `TypeError` on autoload. Renamed to `Unified` to match the
69
+ autoload entry. Two follow-on bugs (mismatched `Suggester.new`
70
+ kwargs and a custom `build_dic_structure` that produced the wrong
71
+ shape) fixed in the same change.
72
+
73
+ ### Changed
74
+
75
+ - **`Embeddings::LruCache#fetch`** now yields the *key* to the provided
76
+ block (matching Ruby's `Hash#fetch` convention) instead of invoking
77
+ the block with no arguments. Callers that compute the missing value
78
+ from the key no longer need a closure over an outer variable.
79
+ - **`Embeddings::SimilarityEngine`** exposes a `pre_normalize?`
80
+ predicate (replacing a dead `attr_reader :pre_normalize` that was
81
+ silently shadowed by a private method of the same name) and now
82
+ raises `ArgumentError` with a clear dimension-mismatch message
83
+ instead of crashing with a `TypeError` from `Array#zip` when given
84
+ vectors of differing lengths. `nil` and empty inputs still return
85
+ `0.0` (early-return path).
86
+ - **Eliminated `respond_to?` across `lib/`.** Replaced every
87
+ occurrence with `is_a?` / `case-when` / `method_defined?` / direct
88
+ calls. Duck-typing via `respond_to?` hid real type errors — the
89
+ production bugs surfaced by this refactor (see Fixed) had been
90
+ silently no-op'ing for as long as the code existed.
91
+ - **Eliminated `send` to private/protected methods** in `lib/`. The
92
+ `cache` CLI subcommand's `cache.send(:read_metadata, ...)` is now
93
+ `cache.read_metadata(...)` after promoting the method to public
94
+ (`public def`). `SemanticStrategy#embedding_for` was dead code that
95
+ called `@search.send(:get_embedding, ...)` on a method that didn't
96
+ exist — removed entirely.
97
+ - **Eliminated `instance_variable_set/get` across `lib/`.**
98
+ `BloomFilter#merge` now reads `other.bits` (public `attr_reader`)
99
+ instead of `other.instance_variable_get(:@bits)`.
100
+ `Dictionary::PlainText` and `Dictionary::CSpell` constructors now
101
+ accept a `words:` keyword instead of `from_words` poking ivars via
102
+ `allocate` + `instance_variable_set`. `Readers::LookupBuilder`
103
+ gained `aff_data:` / `words:` keyword args to replace the same
104
+ pattern in `from_data`.
105
+ - **Configuration dispatch is type-checked, not duck-typed.**
106
+ `Configuration#apply_*` previously called
107
+ `send("#{key}=", value) if respond_to?("#{key}=")`; now uses
108
+ `public_send(setter, value) if self.class.method_defined?(setter)`.
109
+ - **`Dictionary::Base` exposes a default `path` method** returning
110
+ `nil` (overridden by file-backed subclasses). `Spellchecker`
111
+ previously guarded with `dictionary.respond_to?(:path)`; now calls
112
+ unconditionally.
113
+ - **`Language::Registry.info` checks the type bound.** Was
114
+ `klass.respond_to?(:instance)`; now
115
+ `klass.is_a?(Class) && klass <= Kotoshu::Language::Base`.
116
+
117
+ ### Fixed
118
+
119
+ - **`AuditLog#each` now iterates newest-first across current and
120
+ rotations.** The docstring promised newest-first but the
121
+ implementation yielded each file in write order (oldest line first)
122
+ then walked current → oldest file, producing a jumbled order. Each
123
+ file's lines are now read into memory and yielded in reverse before
124
+ walking to the next older rotation.
125
+ - **`Embeddings::Protocol` is no longer broken.** The module declared
126
+ `required_methods` and `required_methods(*names)` (a reader + a
127
+ writer with the same name). Ruby's last-def-wins shadowed the
128
+ reader, so every protocol registration silently dropped its
129
+ arguments and `compliance_errors` returned `[]` unconditionally —
130
+ every class appeared to conform to every protocol. Renamed the
131
+ writer to `required` (and `optional_methods` → `optional`); also
132
+ fixed `compliance_errors` to use `klass.method_defined?` instead of
133
+ `klass.respond_to?` (which checks class methods, not instance
134
+ methods).
135
+ - **`Embeddings::OnnxRuntimeModel` defines `loaded?`.** The protocol
136
+ requires `loaded?` but the class declared only `attr_reader :loaded`
137
+ (no question mark). `EmbeddingPipeline#stats` and `#to_s` called
138
+ `@model.loaded?` — would have raised `NoMethodError` on the real
139
+ model.
140
+ - **`Dictionary::Base` aliases dispatch dynamically.** Ruby's `alias`
141
+ keyword binds to the parent method body at definition time,
142
+ bypassing subclass overrides. `has_word?`/`include?`/`contains?`/
143
+ `lookup?`/`<<`/`all_words` on a concrete subclass (e.g.,
144
+ `UnixWords`) would invoke the abstract `Base#lookup` /
145
+ `Base#add_word` and raise `NotImplementedError`. Replaced with
146
+ one-line method definitions that call the abstract method (which
147
+ then dispatches dynamically to the subclass override).
148
+ - **`Dictionary::CSpell#add_word` no longer crashes.** Tried to
149
+ `@trie.insert` on a frozen `Trie` (frozen by `Builder#build`),
150
+ raising `FrozenError`. Aligned with `remove_word`'s existing
151
+ "always returns false (immutable after load)" contract.
152
+ - **`Cache::FrequencyCache#get_frequency` no longer raises
153
+ `ArgumentError`.** Called `get(language_code, force_download)`
154
+ positionally, but `BaseCache#get` declares `force_download:` as
155
+ keyword. Fixed to use the keyword form.
156
+ - **`Cache::FrequencyCache#cached_resources` no longer reports the
157
+ `tmp/` scratch slot.** `BaseCache#initialize` creates
158
+ `<cache_path>/tmp/` as a working directory; the directory-walking
159
+ implementation in `FrequencyCache` listed it as a cached language.
160
+ Added an explicit `basename != "tmp"` exclusion.
161
+ - **`Cache::FrequencyCache#install_local` exists.** `ResourceManager`
162
+ called `freq_cache.install_local(...)` behind a `respond_to?` guard
163
+ that always returned `false`, so local frequency installs were
164
+ silently skipped — `setup(from:)` reported `frequency_status: :local`
165
+ even though no install happened. Added the method, mirroring
166
+ `LanguageCache#install_local` but taking a single `path:` kwarg.
167
+ - **`PhonetTable::Rule#match?` is no longer dead code.**
168
+ `Algorithms::PhonetSuggest` had its own `match_rule` that
169
+ duplicated the rule-matching logic, ignoring `Rule#match?`
170
+ entirely. Consolidated into `Rule#match_length` (returning
171
+ `Integer|nil` — the metaphone walker needs the length to advance
172
+ its position) with `Rule#match?` as a boolean wrapper;
173
+ `PhonetSuggest#match_rule` now delegates.
174
+ - **`DoubleNegativeMatcher` recognizes the "not only...but also"
175
+ correlative.** The previous check fired only on the narrow shape
176
+ `not [current_negative] only`, missing the actual idiom (where
177
+ "not" and "only" are adjacent). Replaced with a region-based
178
+ matcher that finds every exception phrase in the token stream and
179
+ suppresses any negative whose index falls inside a region. Supports
180
+ both `"prefix...suffix"` (open-ended) and `"literal n-gram"` (no
181
+ ellipsis) phrase shapes, driven by the rule's `exceptions.phrases`
182
+ config.
183
+ - **`SemanticAnalyzer` no longer returns `[]` for OOV words.**
184
+ `suggest_corrections` called `@model.nearest_neighbors(word)`,
185
+ which returns `[]` for OOV inputs — but OOV is precisely what the
186
+ analyzer exists to handle. Added an edit-distance fallback: when
187
+ the model returns `[]`, walk the vocabulary and return words within
188
+ Levenshtein distance 2, scored by `1/(1+distance)`. Drive-by fix:
189
+ `Models::Suggestion.new` was called with `word:` as keyword but
190
+ the constructor takes `word` positional — exposed once the OOV
191
+ fallback actually reached the `Suggestion.new` call.
192
+
193
+ ### Removed
194
+
195
+ - **Orphan files emptied to no-op stubs.**
196
+ `lib/kotoshu/embeddings/protocols.rb` (plural) had
197
+ `require_relative 'protocols/embedding_model'` pointing at
198
+ nonexistent files (loading it raised `LoadError`).
199
+ `lib/kotoshu/readers/readers.rb` (nested) duplicated the autoload
200
+ setup in `lib/kotoshu/readers.rb` with `require_relative` calls
201
+ (violating the project rule against `require_relative` in lib/).
202
+ Both reduced to documentation-only stubs; no callers were affected.
203
+ The real protocol definitions live in
204
+ `lib/kotoshu/embeddings/protocol.rb`; the real reader autoloads
205
+ live in `lib/kotoshu/readers.rb`.
206
+
207
+ ## [0.4.0] — 2026-06-29
208
+
209
+ Tier 2 release. Completes the Hunspell correctness work (morphological
210
+ rules, REP/phonet/ngram ranking, compound handling), migrates the entire
211
+ library from `require_relative` to Ruby `autoload`, and tightens the
212
+ cache interface alignment that 0.3.1 sketched.
213
+
214
+ ### Added
215
+
216
+ - **Hunspell morphological correctness** (T2 Phases 2A–2D):
217
+ - `FLAG long|num|utf-8` parsing and `AF` alias resolution.
218
+ - `COMPOUNDRULE`, `CHECKCOMPOUNDPATTERN` (with empty-flag fix),
219
+ `CIRCUMFIX` handling.
220
+ - `ICONV`/`OCONV` `ConvTable` with stable sort — preserves Nepali
221
+ `ZWNJ`/`ZWJ` ordering.
222
+ - `KEEPCASE` suggestion flag with `CHECKSHARPS` exception, plus the
223
+ German eszet rule (`ss` ↔ `SS`).
224
+ - `REP` table, phonet ordering, ngram ranking wired end-to-end.
225
+ - Edge cases: `IJ` digraph, `i58202` case preservation,
226
+ `opentaal_forbiddenword2`, `breakdefault` `BREAK` pattern.
227
+ - **Hunspell Suggester** is now wired into the Hunspell dictionary; the
228
+ default affix directives are honoured when generating suggestions.
229
+ - **`BaseCache#max_cache_size`** attribute (1 GB default) — the
230
+ forward-looking hook for cache eviction work (see
231
+ `TODO.impl/34-cache-eviction.md`).
232
+ - **`LanguageCache#language_path(lang, type)`** public helper; resource
233
+ and metadata path resolution is now DRY'd through it.
234
+ - **`Kotoshu::Language` suika soft-load module** with specs — keeps
235
+ tokenization lazy and optional.
236
+ - **Multi-language EditDistanceStrategy spec coverage** — pins
237
+ per-language keyboard selection (QWERTZ/AZERTY/QWERTY/JCUKEN) and
238
+ verifies German and French typo correction end-to-end. The previous
239
+ `skip 'Multi-language support not yet implemented'` hook is removed;
240
+ the strategy has been language-aware via `Keyboard::Registry` +
241
+ `Cache::FrequencyCache` for several releases.
242
+
243
+ ### Changed
244
+
245
+ - **Library-wide `autoload` migration.** Every `require_relative` (and
246
+ in-library `require`) under `lib/kotoshu/` is replaced with a Ruby
247
+ `autoload` declared in the immediate parent namespace's file. This
248
+ fixes load-order races, makes the eager/opt-in split explicit, and
249
+ matches the contributor guidance in `CLAUDE.md`. Behaviour is
250
+ unchanged for callers who only `require "kotoshu"`, but extenders who
251
+ reached past the public facade may need to update their load entry
252
+ points.
253
+ - **`Embeddings` namespace consolidation.** All embedding classes are
254
+ nested under `Kotoshu::Embeddings` (was scattered with several
255
+ `require_relative` shims).
256
+ - **Grammar rules bundled in the gem.** English `rules.yaml` ships
257
+ inside the gem package so a `gem install kotoshu` is self-contained
258
+ for grammar checking without a separate download.
259
+ - **Plain-text edit distance is now case-insensitive**, matching the
260
+ Hunspell backend's behaviour for `PlainText` dictionaries.
261
+
262
+ ### Fixed
263
+
264
+ - **`Cli::CacheCommand` wired to the real `LanguageCache` API.** The
265
+ previous implementation called nonexistent methods (`cache_status`,
266
+ `get_frequency_data`, `get_language_info`, `purge_all`); `kotoshu cache`
267
+ now correctly routes through `available?`, `cached_resources`,
268
+ `clear_all`, `clean`, `stats`, `get_spelling`, `get_grammar`, and the
269
+ standalone `Cache::FrequencyCache` for frequency downloads.
270
+ - **`Keyboard::Registry` lazy reload.** `clear` now marks
271
+ `@languages_loaded = true` to prevent an infinite lazy-reload loop
272
+ when the registry is reset at runtime.
273
+ - **German common-words YAML.** `data/common_words/de.yml` line 466 had
274
+ `-ecke` (missing space after dash), a syntax error that prevented the
275
+ file from loading and silently broke German strategy instantiation.
276
+ - **`Readers::AffReader` Hunspell edge cases** — encoding fallback for
277
+ Latin-1 fixtures, `REP` table splitting, `MultiWord` dash variants,
278
+ default affix directives honoured when the `.aff` file omits them.
279
+ - **RuboCop 3.x / plugins migration.** `rubocop-rspec` is bumped to
280
+ 3.x and the config uses the new plugins syntax; safe auto-corrections
281
+ applied across `lib/` and `spec/` (`Layout/ExtraSpacing`,
282
+ `Layout/EmptyLine`).
283
+ - **Cross-platform CI.** `bundle` is invoked via `Gem.ruby -S` for
284
+ Windows compatibility; Ruby 4.0 compatibility fixes applied.
285
+
286
+ ### Internal
287
+
288
+ - `TODO.impl/` planning documents (00–40) are the tiered execution
289
+ plan. Tier 2 is complete; Tier 3 (`audit-log-rotation`,
290
+ `cache-eviction`, `shell-completion`) and content-repo tasks
291
+ (`onnx-vocab-json-generation`) remain deferred past 0.4.0.
292
+
293
+ ## [0.3.1] — 2026-06-28
294
+
295
+ Bug-fix release. Closes the regressions introduced by the 0.3.0
296
+ lutaml-model migration and tightens up spec gating so the default suite
297
+ is green on the non-network, non-ONNX subset.
298
+
299
+ ### Fixed
300
+
301
+ - `WordResult#suggestions` is `Array<Suggestion>` after the lutaml
302
+ migration; the integration spec now reads it as
303
+ `suggestions.map(&:word)` instead of the removed `SuggestionSet#words`.
304
+ - `WordResult#to_h` was removed by lutaml; the integration spec now uses
305
+ the framework-supplied `to_hash`.
306
+ - `Readers::AffReader#detect_encoding` now handles a nil path so the
307
+ reader can be constructed from an in-memory `StringReader` (used by
308
+ the unit tests and by programmatic callers that build affix data
309
+ without a file on disk).
310
+ - `Embeddings::SimilaritySearch` is now eagerly loaded alongside the
311
+ rest of the embeddings namespace; previously the constant was
312
+ uninitialized when `SemanticStrategy` tried to use it.
313
+ - `Embeddings::Vocabulary.from_cache` is implemented (was missing). It
314
+ resolves the `vocab.json` sibling of the cached ONNX model and
315
+ returns nil when the model or vocab is unavailable, so callers can
316
+ degrade gracefully.
317
+ - `Embeddings::OnnxRuntimeModel.from_cache` now accepts the `cache:`
318
+ keyword (matching `Vocabulary.from_cache` and
319
+ `SimilaritySearch.from_cache`); the previous positional signature
320
+ silently swallowed the cache as a Hash.
321
+ - `Suggestions::Strategies::SemanticStrategy` now surfaces its named
322
+ constructor kwargs (`min_semantic_similarity`,
323
+ `semantic_boost_weight`, `max_context_window`) through `get_config`,
324
+ so the strategy's configurable knobs are queryable the same way as
325
+ the base strategy's `**config` bag.
326
+
327
+ ### Changed
328
+
329
+ - `spec/spec_helper.rb` excludes `:onnx`-tagged examples unless
330
+ `ONNX_TESTS=1` is set, mirroring the existing `:network` and `:slow`
331
+ opt-in pattern. `spec/kotoshu/suggestions/strategies/semantic_strategy_spec.rb`
332
+ is the sole consumer; its `:integration` tags are now `:onnx`.
333
+ - The SymSpell benchmark spec and the edit-distance / symspell
334
+ performance describe blocks are tagged `:slow`, removing
335
+ environment-dependent timing flakes from the default run.
336
+ - Arabic language detection is marked `pending` with a pointer to
337
+ `TODO.impl/30-language-auto-detection.md` (FastText LID missing the
338
+ Arabic vector).
339
+ - `edit_distance_strategy_spec` now uses the `from_source?` predicate
340
+ instead of asserting `source == :edit_distance`, matching the
341
+ lutaml-era contract that `Suggestion#source` is a string.
342
+
343
+ ### Internal
344
+
345
+ - Added `TODO.impl/36-cleanup-regressions-0.3.1.md` (this release),
346
+ `TODO.impl/37-hunspell-correctness-tier2.md`,
347
+ `TODO.impl/38-onnx-semantic-gating.md`, and
348
+ `TODO.impl/39-tier3-and-beyond.md` as the tiered execution plan.
9
349
 
10
350
  ## [0.3.0] — 2026-06-27
11
351
 
data/CLAUDE.md CHANGED
@@ -21,6 +21,8 @@ bundle exec rspec -e "matches a word" # Run examples matching a name
21
21
  bundle exec rspec --only-failures # Rerun just failing examples (uses .rspec_status)
22
22
 
23
23
  NETWORK_TESTS=1 bundle exec rspec # Opt INTO tests that download dictionaries
24
+ ONNX_TESTS=1 bundle exec rspec # Opt INTO tests that need onnxruntime + cached models
25
+ SLOW_TESTS=1 bundle exec rspec # Opt INTO benchmarks and full-dictionary sweeps
24
26
  bundle exec rubocop # Lint
25
27
  bundle exec rubocop -A # Lint with safe auto-fix
26
28
  bundle exec rake # default task = spec + rubocop
@@ -32,6 +34,8 @@ gem build kotoshu.gemspec && gem install kotoshu-*.gem
32
34
 
33
35
  Notes that aren't obvious from the Rakefile:
34
36
  - `spec/spec_helper.rb` excludes anything tagged `:network` unless `NETWORK_TESTS=1` is set — those specs download dictionaries from GitHub and are slow/flaky.
37
+ - `spec/spec_helper.rb` excludes anything tagged `:onnx` unless `ONNX_TESTS=1` is set — those specs need `onnxruntime` installed **and** the language model cached via `kotoshu setup :en --model`.
38
+ - `spec/spec_helper.rb` excludes anything tagged `:slow` unless `SLOW_TESTS=1` is set — those are timing-sensitive benchmarks and full-dictionary sweeps.
35
39
  - SimpleCov runs on every `rspec` invocation (configured in `spec_helper.rb`).
36
40
  - `spec/spylls_test_helper.rb` is mixed into every spec. It ports Hunspell's reference test fixtures from [Splylls](https://github.com/neolithos/spylls) (the Python Hunspell port); many specs assert behavior against those fixtures.
37
41
 
data/README.adoc CHANGED
@@ -715,6 +715,39 @@ Total:
715
715
  ======================================================================
716
716
  ----
717
717
 
718
+ === Shell Completion
719
+
720
+ Kotoshu ships tab-completion scripts for bash, zsh, and fish. The scripts
721
+ complete subcommands for `kotoshu <TAB>` and language codes for
722
+ `kotoshu setup <TAB>` (the language list is read dynamically via
723
+ `kotoshu completions languages`, so newly registered languages appear
724
+ without re-running the install step).
725
+
726
+ Install for your shell:
727
+
728
+ [source,bash]
729
+ ----
730
+ # bash (system-wide)
731
+ kotoshu completions bash > /etc/bash_completion.d/kotoshu
732
+
733
+ # bash (single user)
734
+ kotoshu completions bash > ~/.local/share/bash-completion/completions/kotoshu
735
+
736
+ # zsh — drop into any directory on your $fpath
737
+ kotoshu completions zsh > "${fpath[1]}/_kotoshu"
738
+
739
+ # fish
740
+ kotoshu completions fish > ~/.config/fish/completions/kotoshu.fish
741
+ ----
742
+
743
+ To see the supported language codes without installing a completion
744
+ script:
745
+
746
+ [source,bash]
747
+ ----
748
+ kotoshu completions languages
749
+ ----
750
+
718
751
  === Language Support Matrix
719
752
 
720
753
  Kotoshu provides multi-language support with varying feature availability.
@@ -937,6 +970,93 @@ kotoshu setup en --want spelling,model
937
970
  git clone https://github.com/kotoshu/dictionaries.git ~/src/kotoshu/dictionaries
938
971
  ----
939
972
 
973
+ == Writing a Document Plugin
974
+
975
+ Kotoshu ships only `Kotoshu::Documents::PlainTextDocument` for the
976
+ plain-text case. Format-specific parsers (Markdown, AsciiDoc,
977
+ reStructuredText, LaTeX, …) live in plugins — see the
978
+ `kotoshu-document-plugin-boundary` design note. Kotoshu auto-discovers
979
+ plugin files via `Gem.find_files("kotoshu_plugin/document/*.rb")` — install
980
+ the gem and the parser registers itself on first use.
981
+
982
+ A plugin has three pieces:
983
+
984
+ 1. *Parser class* — responds to
985
+ `.from_string(text, language_code:)` (and optionally
986
+ `.from_file(path, language_code:)`). Returns a
987
+ `Kotoshu::Documents::Document` whose `text_nodes` carry proper
988
+ `Documents::SourceRange`s.
989
+ 2. *Plugin file* — placed at
990
+ `lib/kotoshu_plugin/document/<format>.rb` in the gem. The file body
991
+ calls `Kotoshu::Documents.register(format, ParserClass)` at load
992
+ time.
993
+ 3. *Gem dependency* — `kotoshu` (this gem) and whatever
994
+ format-parsing library the parser wraps (e.g. `kramdown` for
995
+ Markdown, `asciidoctor` for AsciiDoc).
996
+
997
+ Example skeleton (`my_markdown_parser` gem):
998
+
999
+ [source,ruby]
1000
+ ----
1001
+ # lib/my_markdown_parser/document_parser.rb
1002
+ module MyMarkdownParser
1003
+ class DocumentParser
1004
+ # Build one or more TextNodes from the Markdown AST, each
1005
+ # carrying a SourceRange that points at the original source.
1006
+ def self.from_string(text, language_code: nil)
1007
+ # ... walk the AST, collect (text, source_offset) per text run,
1008
+ # build TextNodes with SourceRanges ...
1009
+ Kotoshu::Documents::Document.new(
1010
+ text_nodes: nodes,
1011
+ source: text,
1012
+ format: :markdown,
1013
+ language_code: language_code
1014
+ )
1015
+ end
1016
+ end
1017
+ end
1018
+ ----
1019
+
1020
+ [source,ruby]
1021
+ ----
1022
+ # lib/kotoshu_plugin/document/markdown.rb
1023
+ require "kotoshu"
1024
+ require "my_markdown_parser/document_parser"
1025
+ Kotoshu::Documents.register(:markdown, MyMarkdownParser::DocumentParser)
1026
+ ----
1027
+
1028
+ After installing the gem, callers get Markdown parsing for free:
1029
+
1030
+ [source,ruby]
1031
+ ----
1032
+ require "kotoshu"
1033
+
1034
+ doc = Kotoshu::Documents.parse(source, format: :markdown,
1035
+ language_code: "en")
1036
+ errors = Kotoshu::Spellchecker.new(
1037
+ resource_bundle: Kotoshu::ResourceManager.resolve(language: "en")
1038
+ ).check(doc.flattened_text)
1039
+ errors.each do |error|
1040
+ puts "#{error.source_range}: #{error.original} → #{error.recommended_suggestion}"
1041
+ end
1042
+ ----
1043
+
1044
+ The structure-aware contract: the analyzer scans
1045
+ `doc.flattened_text` (concatenation of every node's `text`), but every
1046
+ error carries a `source_range` from the original source. An editor can
1047
+ use that range to highlight the user's actual markup, not the
1048
+ stripped text. For example, on the source
1049
+
1050
+ I'm an **friend** of Tom
1051
+
1052
+ a grammar error "an friend" → "a friend" carries a source_range
1053
+ pointing at the original `an **friend**` range, not the flattened
1054
+ "an friend" range.
1055
+
1056
+ Plugin gems should publish to RubyGems with a runtime dependency on
1057
+ `kotoshu >= 0.5.0`. Format-specific parsers are the plugin's
1058
+ responsibility — kotoshu never owns document parsing.
1059
+
940
1060
  == License
941
1061
 
942
1062
  BSD 2-Clause — see the link:LICENSE[LICENSE] file for details.
@@ -0,0 +1,117 @@
1
+ # 36 — Cleanup Regressions + 0.3.1 Bug-Fix Release
2
+
3
+ ## Goal
4
+
5
+ Ship `kotoshu-0.3.1` — a focused bug-fix release that closes the
6
+ regressions introduced by the 0.3.0 lutaml-model migration and a small
7
+ set of pre-existing spec failures. This is the **only** Tier-1 work;
8
+ its acceptance criterion is a clean spec run on the non-network,
9
+ non-ONNX subset.
10
+
11
+ ## Scope (locked)
12
+
13
+ | # | Failure cluster | Root cause | Fix shape |
14
+ |---|---|---|---|
15
+ | 1 | `spec/integration/walking_skeleton_spec.rb:22` — `result.suggestions.words` | Lutaml migration changed `WordResult#suggestions` from `SuggestionSet` to `Array<Suggestion>`. | Update spec to `result.suggestions.map(&:word)`. |
16
+ | 2 | `spec/integration/walking_skeleton_spec.rb:265` — `result.to_h` | Lutaml-model replaced hand-rolled `to_h`. The instance method is gone; framework supplies `to_hash`. | Update spec to call `result.to_hash` (lutaml-supplied). |
17
+ | 3 | `spec/kotoshu/suggestions/strategies/semantic_strategy_spec.rb` (22 failures) | Specs assume `onnxruntime` is installed and a model is loaded. Without ONNX, every example errors. | Tag the spec file `:onnx` and exclude by default; document the opt-in env (`ONNX_TESTS=1`) in CLAUDE.md. Mirror the `:network` opt-in pattern. |
18
+ | 4 | `spec/unit/hunspell/read_aff_spec.rb` (7 failures) | Real parser bugs in REP, MAP, PFX directive parsing and UTF-8 / numeric / long flag formats. | Fix each parser branch with a targeted spec; do **not** rewrite the whole reader. |
19
+ | 5 | `spec/kotoshu/language/detector_spec.rb:56` — Arabic detection | Pre-existing FastText LID gap. | Mark `pending` with a TODO referencing the upstream model; do not block the release on it. |
20
+ | 6 | `spec/benchmark/symspell_benchmark_spec.rb` (1 failure) | Likely environment-dependent timing. | Audit; if real bug, fix; if env noise, mark `:slow`. |
21
+
22
+ **Out of scope:** the 90+ `spec/integrational/{lookup,suggest}_spec.rb`
23
+ Hunspell-fixture failures. Those are T2 (`TODO.impl/37-hunspell-correctness-tier2.md`).
24
+
25
+ ## Phase A — Lutaml regression cleanups
26
+
27
+ ### A1. walking_skeleton_spec
28
+
29
+ - Line 28: `expect(result.suggestions.words).to include("hello")`
30
+ → `expect(result.suggestions.map(&:word)).to include("hello")`
31
+ - Line 268: `hash = result.to_h`
32
+ → `hash = result.to_hash` (lutaml-model supplies this)
33
+
34
+ ### A2. semantic_strategy_spec gating
35
+
36
+ - Add `describe ..., :onnx do` or `RSpec.describe ... do; before { skip "set ONNX_TESTS=1" unless ENV["ONNX_TESTS"] } end`.
37
+ - Update `spec/spec_helper.rb` to exclude `onnx: true` unless `ONNX_TESTS=1`.
38
+ - Update `CLAUDE.md` "Notes" section to mention the opt-in.
39
+
40
+ ### A3. Audit other lutaml fallout
41
+
42
+ - `grep -rn "\.suggestions\.words\|\.suggestions\.to_a\|\.to_h\b" spec/`
43
+ (only the two walking_skeleton hits — confirmed.)
44
+ - `grep -rn "\.as_json\b" spec/` — already replaced in cli.rb; verify
45
+ no spec calls the removed instance method.
46
+
47
+ **Acceptance:** `bundle exec rspec --tag ~network --tag ~onnx` shows
48
+ **0 failures** attributable to lutaml migration.
49
+
50
+ ## Phase B — Hunspell .aff parser fixes
51
+
52
+ For each failing directive, add a focused spec covering the exact
53
+ shape from the failing fixture, then fix the parser branch.
54
+
55
+ ### B1. REP directive (read_aff_spec:12)
56
+
57
+ Repairs — substitution pairs used by the suggester. Currently parsed
58
+ incorrectly.
59
+
60
+ ### B2. MAP directive (read_aff_spec:31)
61
+
62
+ Character equivalence classes (e.g. `MAP uü`, `MAP aàáâã`). Sets up
63
+ related-character substitution in suggestions.
64
+
65
+ ### B3. PFX directive (read_aff_spec:47)
66
+
67
+ Prefix rules. Currently fails to parse multi-rule PFX blocks. Likely
68
+ the same bug as SFX since they share the parser.
69
+
70
+ ### B4. Long flag format (read_aff_spec:62)
71
+
72
+ `FLAG long` mode — two-character flags.
73
+
74
+ ### B5. UTF-8 flag format (read_aff_spec:106)
75
+
76
+ `FLAG UTF-8` mode — flags are single Unicode codepoints.
77
+
78
+ ### B6. Numeric flag format (read_aff_spec:125)
79
+
80
+ `FLAG num` mode — integer flags.
81
+
82
+ ### B7. AF directive / flag aliases (read_aff_spec:125)
83
+
84
+ `AF` directive defines aliases used to keep `.dic` files compact.
85
+
86
+ **Acceptance:** all 7 `read_aff_spec` examples green; no regression in
87
+ existing `algorithms/` specs.
88
+
89
+ ## Phase C — Pre-existing pending/skips
90
+
91
+ - Arabic detection: `pending "FastText LID missing Arabic vector — see TODO.impl/30-language-auto-detection.md"`.
92
+ - Symspell benchmark: audit; if real, fix; if env noise, `:slow`.
93
+
94
+ ## Phase D — Cut 0.3.1
95
+
96
+ 1. Bump `lib/kotoshu/version.rb` → `0.3.1`.
97
+ 2. Update CHANGELOG (or create if missing).
98
+ 3. Update README quickstart if it references any 0.3.0-broken API.
99
+ 4. Run full suite (`bundle exec rspec --tag ~network --tag ~onnx`)
100
+ green; `bundle exec rubocop` clean.
101
+ 5. PR (not direct-to-main — see global rule).
102
+ 6. After merge: tag `v0.3.1` (the **user** does this; never the agent).
103
+
104
+ ## Release-level acceptance criteria
105
+
106
+ - `bundle exec rspec --tag ~network --tag ~onnx` → 0 failures
107
+ (excluding the explicitly-pending FastText Arabic case).
108
+ - `bundle exec rubocop` → clean.
109
+ - Lutaml migration regressions: 0.
110
+ - Pre-existing Hunspell `.aff` parser bugs: 0.
111
+ - No new public API changes — 0.3.1 is strictly a bug-fix release.
112
+
113
+ ## Dependencies
114
+
115
+ - **Blocked by:** nothing.
116
+ - **Blocks:** `TODO.impl/37-hunspell-correctness-tier2.md` (the next
117
+ tier of Hunspell-fixture work).