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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/Rakefile +36 -0
- data/lib/minifts/searchable_map.rb +401 -0
- data/lib/minifts/version.rb +5 -0
- data/lib/minifts.rb +944 -0
- metadata +54 -0
|
@@ -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
|