minifts 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.
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MiniFTS
4
+ # A radix tree (compressed prefix tree) implementing a Map-like interface with
5
+ # string keys, plus efficient prefix and fuzzy (Levenshtein) lookup. Used
6
+ # internally by {MiniFTS} as the inverted index, but useful on its own.
7
+ #
8
+ # This is a faithful port of MiniSearch's `SearchableMap`. The tree is a nested
9
+ # Hash: every non-empty string key is an edge label pointing at a child Hash,
10
+ # and the empty-string key ({LEAF}) holds the value stored at that node.
11
+ class SearchableMap
12
+ include Enumerable
13
+
14
+ # Sentinel key marking a node that carries a stored value. It is the empty
15
+ # string, which can never be an edge label (edge labels are non-empty).
16
+ LEAF = ""
17
+
18
+ # @internal the raw nested-Hash tree
19
+ attr_reader :tree
20
+
21
+ # @internal the prefix this (possibly derived) view is rooted at
22
+ attr_reader :prefix
23
+
24
+ # Normally called without arguments to create an empty map. The arguments are
25
+ # for internal use, when building a derived view at a prefix (see {#at_prefix}).
26
+ def initialize(tree = {}, prefix = "")
27
+ @tree = tree
28
+ @prefix = prefix
29
+ @size = nil
30
+ end
31
+
32
+ # Returns a mutable view of this map containing only entries whose keys start
33
+ # with +prefix+.
34
+ def at_prefix(prefix)
35
+ raise Error, "Mismatched prefix" unless prefix.start_with?(@prefix)
36
+
37
+ node, path = track_down(@tree, tail(prefix, @prefix.length))
38
+
39
+ if node.nil?
40
+ parent_node, key = path.last
41
+ parent_node.each_key do |k|
42
+ next unless k != LEAF && k.start_with?(key)
43
+
44
+ child = {}
45
+ child[tail(k, key.length)] = parent_node[k]
46
+ return SearchableMap.new(child, prefix)
47
+ end
48
+ end
49
+
50
+ # When no subtree matches, MiniSearch relies on the constructor's default
51
+ # (`tree = new Map()`) to yield an empty, still-iterable view.
52
+ SearchableMap.new(node.nil? ? {} : node, prefix)
53
+ end
54
+
55
+ # Removes all entries.
56
+ def clear
57
+ @size = nil
58
+ @tree.clear
59
+ nil
60
+ end
61
+
62
+ # Deletes the entry at +key+, if present.
63
+ def delete(key)
64
+ @size = nil
65
+ remove(@tree, key)
66
+ nil
67
+ end
68
+
69
+ # Yields (or returns an Enumerator over) +[key, value]+ pairs, in the same
70
+ # order as MiniSearch's tree iterator (depth-first, siblings in reverse
71
+ # insertion order).
72
+ def each(&block)
73
+ return enum_for(:each) unless block_given?
74
+
75
+ dfs(@tree, @prefix, &block)
76
+ self
77
+ end
78
+
79
+ # @return [Array<Array>] all +[key, value]+ entries
80
+ def entries
81
+ map { |entry| entry }
82
+ end
83
+
84
+ # @return [Array<String>] all keys
85
+ def keys
86
+ map { |entry| entry[0] }
87
+ end
88
+
89
+ # @return [Array] all values
90
+ def values
91
+ map { |entry| entry[1] }
92
+ end
93
+
94
+ # Returns a Hash mapping each matching key to a +[value, edit_distance]+ pair,
95
+ # for every key within +max_edit_distance+ (Levenshtein) of +key+.
96
+ def fuzzy_get(key, max_edit_distance)
97
+ fuzzy_search(@tree, key, max_edit_distance)
98
+ end
99
+
100
+ # @return [Object, nil] the value at +key+, or +nil+ if absent
101
+ def get(key)
102
+ node = lookup(@tree, key)
103
+ node.nil? ? nil : node[LEAF]
104
+ end
105
+
106
+ # @return [Boolean] whether +key+ is present
107
+ def has?(key)
108
+ node = lookup(@tree, key)
109
+ !node.nil? && node.key?(LEAF)
110
+ end
111
+
112
+ # Sets +key+ to +value+. Returns self, to allow chaining.
113
+ def set(key, value)
114
+ raise Error, "key must be a string" unless key.is_a?(String)
115
+
116
+ @size = nil
117
+ node = create_path(@tree, key)
118
+ node[LEAF] = value
119
+ self
120
+ end
121
+
122
+ # @return [Integer] the number of entries
123
+ def size
124
+ return @size unless @size.nil?
125
+
126
+ @size = 0
127
+ each { @size += 1 }
128
+ @size
129
+ end
130
+
131
+ # Updates the value at +key+ using the given block, which receives the current
132
+ # value (or +nil+). Returns self.
133
+ def update(key)
134
+ raise Error, "key must be a string" unless key.is_a?(String)
135
+
136
+ @size = nil
137
+ node = create_path(@tree, key)
138
+ node[LEAF] = yield(node[LEAF])
139
+ self
140
+ end
141
+
142
+ # Fetches the value at +key+, calling the block to create and store it if
143
+ # absent. Returns the existing or newly created value.
144
+ def fetch(key)
145
+ raise Error, "key must be a string" unless key.is_a?(String)
146
+
147
+ @size = nil
148
+ node = create_path(@tree, key)
149
+ node[LEAF] = yield unless node.key?(LEAF)
150
+ node[LEAF]
151
+ end
152
+
153
+ # Builds a SearchableMap from an iterable of +[key, value]+ entries.
154
+ def self.from(entries)
155
+ map = new
156
+ entries.each { |key, value| map.set(key, value) }
157
+ map
158
+ end
159
+
160
+ # Builds a SearchableMap from a Hash of entries.
161
+ def self.from_object(object)
162
+ from(object)
163
+ end
164
+
165
+ private
166
+
167
+ # Depth-first traversal matching MiniSearch's TreeIterator: within each node,
168
+ # keys are visited in reverse insertion order, diving fully into each child
169
+ # before moving on.
170
+ def dfs(node, prefix, &block)
171
+ return if node.nil?
172
+
173
+ node.keys.reverse_each do |k|
174
+ if k == LEAF
175
+ block.call([prefix, node[k]])
176
+ else
177
+ dfs(node[k], prefix + k, &block)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Descends the tree consuming +key+, recording the path of +[node, edge]+
183
+ # pairs. Returns +[node, path]+ where node is the reached subtree or +nil+.
184
+ def track_down(tree, key, path = [])
185
+ return [tree, path] if key.empty? || tree.nil?
186
+
187
+ tree.each_key do |k|
188
+ next unless k != LEAF && key.start_with?(k)
189
+
190
+ path.push([tree, k])
191
+ return track_down(tree[k], tail(key, k.length), path)
192
+ end
193
+
194
+ path.push([tree, key])
195
+ track_down(nil, "", path)
196
+ end
197
+
198
+ # Returns the subtree reached by consuming +key+, or +nil+ if the path breaks.
199
+ def lookup(tree, key)
200
+ return tree if key.empty? || tree.nil?
201
+
202
+ tree.each_key do |k|
203
+ return lookup(tree[k], tail(key, k.length)) if k != LEAF && key.start_with?(k)
204
+ end
205
+
206
+ nil
207
+ end
208
+
209
+ # Creates the path for +key+ and returns the deepest node, splitting edges as
210
+ # needed. Hot path for indexing; avoids extra string work and recursion.
211
+ def create_path(node, key)
212
+ key_length = key.length
213
+ pos = 0
214
+
215
+ while node && pos < key_length
216
+ # Find the (unique) child edge whose first character is key[pos]. Edge
217
+ # labels from a node have distinct first characters, so we prefilter each
218
+ # edge on its first byte — getbyte allocates nothing — and only materialize
219
+ # the 1-char strings to confirm on a byte match. This finds the same edge
220
+ # as a direct char comparison while skipping the per-edge String
221
+ # allocations that dominated indexing, and stays correct for multibyte
222
+ # terms (café, résumé): a byte collision between different characters still
223
+ # falls through to the exact k[0] == key_char check.
224
+ key_char = key[pos]
225
+ key_byte = key_char.getbyte(0)
226
+ found_k = nil
227
+ node.each_key do |k|
228
+ next if k == LEAF || k.getbyte(0) != key_byte
229
+
230
+ # start_with? confirms the full first character in place — same result as
231
+ # k[0] == key_char (key_char is exactly one character) but without
232
+ # allocating k[0] on every byte match.
233
+ if k.start_with?(key_char)
234
+ found_k = k
235
+ break
236
+ end
237
+ end
238
+
239
+ if found_k.nil?
240
+ child = {}
241
+ node[tail(key, pos)] = child
242
+ return child
243
+ end
244
+
245
+ k = found_k
246
+ len = [key_length - pos, k.length].min
247
+
248
+ offset = 1
249
+ offset += 1 while offset < len && key[pos + offset] == k[offset]
250
+
251
+ child = node[k]
252
+ if offset == k.length
253
+ node = child
254
+ else
255
+ intermediate = {}
256
+ intermediate[tail(k, offset)] = child
257
+ node[key[pos, offset]] = intermediate
258
+ node.delete(k)
259
+ node = intermediate
260
+ end
261
+
262
+ pos += offset
263
+ end
264
+
265
+ node
266
+ end
267
+
268
+ # Removes +key+ and compresses the tree back down where a node is left with a
269
+ # single child.
270
+ def remove(tree, key)
271
+ node, path = track_down(tree, key)
272
+ return if node.nil?
273
+
274
+ node.delete(LEAF)
275
+
276
+ if node.empty?
277
+ cleanup(path)
278
+ elsif node.size == 1
279
+ k, value = node.first
280
+ merge(path, k, value)
281
+ end
282
+ end
283
+
284
+ def cleanup(path)
285
+ return if path.empty?
286
+
287
+ node, key = path.last
288
+ node.delete(key)
289
+
290
+ if node.empty?
291
+ cleanup(path[0...-1])
292
+ elsif node.size == 1
293
+ k, value = node.first
294
+ merge(path[0...-1], k, value) if k != LEAF
295
+ end
296
+ end
297
+
298
+ def merge(path, key, value)
299
+ return if path.empty?
300
+
301
+ node, node_key = path.last
302
+ node[node_key + key] = value
303
+ node.delete(node_key)
304
+ end
305
+
306
+ # JavaScript String.prototype.slice(n): the tail from index n, or "" if n is
307
+ # at or past the end.
308
+ def tail(str, n)
309
+ n >= str.length ? "" : str[n..-1]
310
+ end
311
+
312
+ # Levenshtein search over the radix tree. Returns a Hash of matching key to
313
+ # +[value, distance]+. A single reused matrix is threaded through the
314
+ # recursion, which pays off for larger edit distances.
315
+ def fuzzy_search(node, query, max_distance)
316
+ results = {}
317
+ return results if query.nil?
318
+
319
+ # Number of columns in the Levenshtein matrix.
320
+ n = query.length + 1
321
+
322
+ # Matching terms can never be longer than n + max_distance.
323
+ m = n + max_distance
324
+
325
+ matrix = Array.new(m * n, max_distance + 1)
326
+ (0...n).each { |j| matrix[j] = j }
327
+ (1...m).each { |i| matrix[i * n] = i }
328
+
329
+ # Descend comparing in integer codepoint space. query[j] and key[pos] each
330
+ # allocate a throwaway 1-char String, and query[j] runs on every Levenshtein
331
+ # matrix cell — together the dominant fuzzy-search allocation. Codepoints are
332
+ # immediate Integers, so equality on them is allocation-free and identical: a
333
+ # character equals another iff their codepoints match (multibyte included).
334
+ # Accumulate the matched term as a path of edge labels (push/pop, no
335
+ # allocation) and join it only at recorded leaves, rather than building a
336
+ # prefix String on every descent — most descents never record a result.
337
+ fuzzy_recurse(node, query.codepoints, max_distance, results, matrix, 1, n, [])
338
+ results
339
+ end
340
+
341
+ def fuzzy_recurse(node, query_cps, max_distance, results, matrix, m, n, path)
342
+ offset = m * n
343
+
344
+ node.each_key do |key|
345
+ if key == LEAF
346
+ # Reached a leaf: record the value if the edit distance is acceptable.
347
+ # A nil distance means the term is longer than any possible match
348
+ # (matrix row out of range); mirror JS, where undefined <= n is false.
349
+ distance = matrix[offset - 1]
350
+ results[path.join] = [node[key], distance] if !distance.nil? && distance <= max_distance
351
+ next
352
+ end
353
+
354
+ # Walk the characters of this edge, updating the matrix. Stop early if the
355
+ # minimum distance in the current row exceeds the maximum: it can only
356
+ # grow from here, so no descendant can match. Iterate the edge's codepoints
357
+ # on the fly with each_codepoint (immediate Integers, no per-edge array)
358
+ # rather than materializing key.codepoints — the top remaining allocation.
359
+ i = m
360
+ skip = false
361
+ key.each_codepoint do |char|
362
+ this_row_offset = n * i
363
+ prev_row_offset = this_row_offset - n
364
+
365
+ min_distance = matrix[this_row_offset]
366
+
367
+ jmin = [0, i - max_distance - 1].max
368
+ jmax = [n - 1, i + max_distance].min
369
+
370
+ j = jmin
371
+ while j < jmax
372
+ different = char == query_cps[j] ? 0 : 1
373
+ rpl = matrix[prev_row_offset + j] + different
374
+ del = matrix[prev_row_offset + j + 1] + 1
375
+ ins = matrix[this_row_offset + j] + 1
376
+ dist = [rpl, del, ins].min
377
+ matrix[this_row_offset + j + 1] = dist
378
+ min_distance = dist if dist < min_distance
379
+ j += 1
380
+ end
381
+
382
+ # Once past the last matrix row (nil min_distance) the term is too long
383
+ # to ever match; matches JS, where undefined > n is false, so it simply
384
+ # keeps descending and records nothing.
385
+ if !min_distance.nil? && min_distance > max_distance
386
+ skip = true
387
+ break
388
+ end
389
+
390
+ i += 1
391
+ end
392
+
393
+ next if skip
394
+
395
+ path.push(key)
396
+ fuzzy_recurse(node[key], query_cps, max_distance, results, matrix, i, n, path)
397
+ path.pop
398
+ end
399
+ end
400
+ end
401
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MiniFTS
4
+ VERSION = "1.0.0"
5
+ end