dyslexicplusplus 0.0.5__py3-none-any.whl
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.
- dyslexicplusplus/Autocorrector.py +658 -0
- dyslexicplusplus/Hasher.py +43 -0
- dyslexicplusplus/HyperLogLog.py +9 -0
- dyslexicplusplus/HyperLogLogPlusPlus.py +362 -0
- dyslexicplusplus/__init__.py +5 -0
- dyslexicplusplus/compare.py +55 -0
- dyslexicplusplus/compare3.py +57 -0
- dyslexicplusplus/test_files/20k_database.txt +20449 -0
- dyslexicplusplus/test_files/20k_shun4midx.txt +20449 -0
- dyslexicplusplus/test_files/database.txt +445 -0
- dyslexicplusplus/test_files/texting.txt +168 -0
- dyslexicplusplus-0.0.5.dist-info/METADATA +104 -0
- dyslexicplusplus-0.0.5.dist-info/RECORD +15 -0
- dyslexicplusplus-0.0.5.dist-info/WHEEL +4 -0
- dyslexicplusplus-0.0.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
############################################
|
|
2
|
+
# Copyright (c) 2026 Shun/修海 (@shun4midx) #
|
|
3
|
+
# Project: HyperLogLogPlusPlus-Autocorrect #
|
|
4
|
+
# File Type: Python file #
|
|
5
|
+
# File: Autocorrector.py #
|
|
6
|
+
############################################
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Dict, List, Sequence, Union
|
|
14
|
+
|
|
15
|
+
from .HyperLogLogPlusPlus import HyperLogLogPlusPlus, SketchConfig
|
|
16
|
+
|
|
17
|
+
SuggestionValue = Union[str, List[str]]
|
|
18
|
+
ScoreValue = Union[float, List[float]]
|
|
19
|
+
|
|
20
|
+
def extract_qgrams(word, q=2, fuzzier=None): # `fuzzier` is retained only for backwards compatibility
|
|
21
|
+
# Depreciation notice
|
|
22
|
+
if fuzzier is not None:
|
|
23
|
+
warnings.warn("`fuzzier` is retained only for backwards compatibility and has no effect in the HLL++ structural algorithm.", DeprecationWarning, stacklevel=2)
|
|
24
|
+
|
|
25
|
+
# Code
|
|
26
|
+
if len(word) < q:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
padded = f" {word} "
|
|
30
|
+
|
|
31
|
+
if q != 2:
|
|
32
|
+
return {padded[i : i + q] for i in range(len(padded) - q + 1)}
|
|
33
|
+
|
|
34
|
+
qgrams = []
|
|
35
|
+
|
|
36
|
+
for i in range(len(padded) - 1):
|
|
37
|
+
qgram = padded[i : i + 2]
|
|
38
|
+
|
|
39
|
+
qgrams.append(qgram)
|
|
40
|
+
qgrams.append(f"{qgram[1]}{qgram[0]}")
|
|
41
|
+
|
|
42
|
+
return qgrams
|
|
43
|
+
|
|
44
|
+
# ======== REPACKAGING ======== #
|
|
45
|
+
|
|
46
|
+
def is_valid(word, letters=None):
|
|
47
|
+
if letters is None:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
return all(character in letters for character in word.lower() )
|
|
51
|
+
|
|
52
|
+
def _read_source(src):
|
|
53
|
+
"""
|
|
54
|
+
Read words from:
|
|
55
|
+
- a list or tuple;
|
|
56
|
+
- a file containing one word per line;
|
|
57
|
+
- or a single literal word.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(src, (list, tuple)):
|
|
60
|
+
return [str(item) for item in src]
|
|
61
|
+
|
|
62
|
+
if not isinstance(src, str):
|
|
63
|
+
raise ValueError(f"`src` ({src!r}) must be a list/tuple, a file path, or a string")
|
|
64
|
+
|
|
65
|
+
if os.path.isfile(src):
|
|
66
|
+
with open(src, "r", encoding="utf-8") as file:
|
|
67
|
+
return [line.strip() for line in file if line.strip()]
|
|
68
|
+
|
|
69
|
+
# A string that looks like a path should not silently become one query.
|
|
70
|
+
if (src.endswith(".txt") or os.path.sep in src or (os.path.altsep is not None and os.path.altsep in src)):
|
|
71
|
+
raise FileNotFoundError(f"Input file not found: {os.path.abspath(src)}")
|
|
72
|
+
|
|
73
|
+
# Otherwise, treat it as one literal word.
|
|
74
|
+
return [src]
|
|
75
|
+
|
|
76
|
+
def load_words(src, letters=None):
|
|
77
|
+
raw = _read_source(src)
|
|
78
|
+
valid_raw = [word for word in raw if is_valid(word, letters)]
|
|
79
|
+
|
|
80
|
+
words = [word.lower() for word in valid_raw]
|
|
81
|
+
display = {word.lower(): word for word in valid_raw}
|
|
82
|
+
|
|
83
|
+
return words, display
|
|
84
|
+
|
|
85
|
+
def load_queries(src):
|
|
86
|
+
raw = _read_source(src)
|
|
87
|
+
return [(word, word.lower()) for word in raw]
|
|
88
|
+
|
|
89
|
+
addon_files = ["texting"] # Files to addon 20k_database.txt
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Results:
|
|
93
|
+
suggestions: Dict[str, SuggestionValue]
|
|
94
|
+
scores: Dict[str, ScoreValue]
|
|
95
|
+
|
|
96
|
+
class Autocorrector:
|
|
97
|
+
# The algorithm relies on reverse-closed padded q-grams, a separate loose reversed channel, adjacent-transposition rescue, and keyboard-aware edit distance.
|
|
98
|
+
# Default non-custom imported keyboards here would disregarded special characters (such as commas, not things like é and ö). Please import your own if you need to. I consider number rows too.
|
|
99
|
+
# Of course, Dvorak is not as intuitive. I replaced special characters with a whitespace for sake of consistency.
|
|
100
|
+
# "a-z" only considers English letters. For French, for example, you can import valid_letters = ["a-z", "é", "É", "à", "À", "ê", "Ê", "è", "È"]
|
|
101
|
+
def __init__(self, dictionary_list=os.path.join("test_files", "20k_database.txt"), valid_letters="a-z", keyboard="qwerty", *, alpha=None, beta=0.85, b=10, shortlist_size=100, keyboard_shortlist_size=75, transposition_bonus=0.35):
|
|
102
|
+
self.letters = self._build_valid_letter_set(valid_letters)
|
|
103
|
+
self.keyboard = self._build_keyboard(keyboard)
|
|
104
|
+
self.KEY_POS = self._build_key_positions(self.keyboard)
|
|
105
|
+
self.KEY_COST = self._build_key_costs(self.KEY_POS)
|
|
106
|
+
|
|
107
|
+
self.word_dict, self.display_map = (self._load_dictionary(dictionary_list))
|
|
108
|
+
|
|
109
|
+
self.alpha = alpha
|
|
110
|
+
if alpha is not None:
|
|
111
|
+
warnings.warn("`alpha` is retained only for backwards compatibility and has no effect in the HLL++ structural algorithm.", DeprecationWarning, stacklevel=2)
|
|
112
|
+
|
|
113
|
+
self.beta = float(beta)
|
|
114
|
+
self.b = int(b)
|
|
115
|
+
self.shortlist_size = int(shortlist_size)
|
|
116
|
+
self.keyboard_shortlist_size = int(keyboard_shortlist_size)
|
|
117
|
+
self.transposition_bonus = float(transposition_bonus)
|
|
118
|
+
|
|
119
|
+
if self.shortlist_size <= 0:
|
|
120
|
+
raise ValueError("`shortlist_size` must be positive")
|
|
121
|
+
|
|
122
|
+
if self.keyboard_shortlist_size <= 0:
|
|
123
|
+
raise ValueError("`keyboard_shortlist_size` must be positive")
|
|
124
|
+
|
|
125
|
+
self.removed_words = set()
|
|
126
|
+
self.compact_threshold = 0.1
|
|
127
|
+
|
|
128
|
+
self.save_dictionary()
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _build_valid_letter_set(valid_letters):
|
|
132
|
+
if valid_letters in (None, "", []):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if isinstance(valid_letters, str):
|
|
136
|
+
valid_letters = [valid_letters]
|
|
137
|
+
elif not isinstance(valid_letters, list):
|
|
138
|
+
raise ValueError(f"`valid_letters` ({valid_letters!r}) must be a string or a list")
|
|
139
|
+
|
|
140
|
+
letters = []
|
|
141
|
+
|
|
142
|
+
for letter in valid_letters:
|
|
143
|
+
if letter == "a-z":
|
|
144
|
+
letters.extend(chr(ord("a") + i) for i in range(26))
|
|
145
|
+
elif letter == "0-9":
|
|
146
|
+
letters.extend(chr(ord("0") + i) for i in range(10))
|
|
147
|
+
elif (isinstance(letter, str) and len(letter) == 1 and letter != " "):
|
|
148
|
+
letters.append(letter.lower())
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError('''`valid_letters` must contain single non-space characters or the abbreviations "a-z" and "0-9"''')
|
|
151
|
+
|
|
152
|
+
return set(letters)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _build_keyboard(keyboard):
|
|
156
|
+
presets = {
|
|
157
|
+
"qwerty": ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"],
|
|
158
|
+
"azerty": ["1234567890", "azertyuiop", "qsdfghjklm", "wxcvbn"],
|
|
159
|
+
"qwertz": ["1234567890", "qwertzuiopü", "asdfghjklöä", "yxcvbnm"],
|
|
160
|
+
"dvorak": ["1234567890", "' pyfgcrl", "aoeuidhtns", " qjkxbmwvz"],
|
|
161
|
+
"colemak": ["1234567890", "qwfpgjluy", "arstdhneio", "zxcvbkm"],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if isinstance(keyboard, str):
|
|
165
|
+
if keyboard not in presets:
|
|
166
|
+
raise ValueError('''`keyboard` must be one of "qwerty", "azerty", "qwertz", "dvorak", or "colemak", or a custom list of rows''')
|
|
167
|
+
return presets[keyboard]
|
|
168
|
+
|
|
169
|
+
if not isinstance(keyboard, list) or not all(isinstance(row, str) for row in keyboard):
|
|
170
|
+
raise ValueError("`keyboard` must be a preset name or a list of row strings")
|
|
171
|
+
|
|
172
|
+
return keyboard
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _build_key_positions(keyboard):
|
|
176
|
+
positions = {}
|
|
177
|
+
|
|
178
|
+
for row_index, row in enumerate(keyboard):
|
|
179
|
+
for column_index, character in enumerate(row):
|
|
180
|
+
positions[character] = (row_index, column_index)
|
|
181
|
+
|
|
182
|
+
return positions
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _build_key_costs(key_positions):
|
|
186
|
+
costs = {}
|
|
187
|
+
|
|
188
|
+
for a, (xa, ya) in key_positions.items():
|
|
189
|
+
for b, (xb, yb) in key_positions.items():
|
|
190
|
+
costs[(a, b)] = math.sqrt((xa - xb)**2 + (ya - yb)**2)
|
|
191
|
+
return costs
|
|
192
|
+
|
|
193
|
+
def _load_dictionary(self, dictionary_list):
|
|
194
|
+
# 1) Already a Python sequence?
|
|
195
|
+
if isinstance(dictionary_list, (list, tuple)):
|
|
196
|
+
return load_words(dictionary_list, self.letters)
|
|
197
|
+
|
|
198
|
+
# 2) File on disk?
|
|
199
|
+
if dictionary_list in addon_files:
|
|
200
|
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
201
|
+
base_path = os.path.join(base_dir, "test_files", "20k_database.txt")
|
|
202
|
+
addon_path = os.path.join(base_dir, "test_files", f"{dictionary_list}.txt")
|
|
203
|
+
combined_words = (_read_source(base_path) + _read_source(addon_path))
|
|
204
|
+
return load_words(combined_words, self.letters)
|
|
205
|
+
|
|
206
|
+
if isinstance(dictionary_list, str) and os.path.isfile(dictionary_list):
|
|
207
|
+
return load_words(os.path.abspath(dictionary_list), self.letters)
|
|
208
|
+
|
|
209
|
+
# 3) String?
|
|
210
|
+
if not isinstance(dictionary_list, str):
|
|
211
|
+
raise ValueError("`dictionary_list` must be a list/tuple, a path, or a known addon")
|
|
212
|
+
|
|
213
|
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
214
|
+
dictionary_path = os.path.join(base_dir, dictionary_list)
|
|
215
|
+
|
|
216
|
+
if not os.path.isfile(dictionary_path):
|
|
217
|
+
raise FileNotFoundError(f"Dictionary file not found: {dictionary_path}")
|
|
218
|
+
|
|
219
|
+
return load_words(dictionary_path, self.letters)
|
|
220
|
+
|
|
221
|
+
def key_dist(self, a, b):
|
|
222
|
+
if a == b:
|
|
223
|
+
return 0.0
|
|
224
|
+
return self.KEY_COST.get((a, b), 1.0)
|
|
225
|
+
|
|
226
|
+
def word_dist(self, a, b): # Keyboard-aware Levenshtein
|
|
227
|
+
na = len(a)
|
|
228
|
+
nb = len(b)
|
|
229
|
+
|
|
230
|
+
previous = [float(j) for j in range(nb + 1)]
|
|
231
|
+
current = [0.0] * (nb + 1)
|
|
232
|
+
|
|
233
|
+
for i in range(1, na + 1):
|
|
234
|
+
current[0] = float(i)
|
|
235
|
+
ai = a[i - 1]
|
|
236
|
+
|
|
237
|
+
for j in range(1, nb + 1):
|
|
238
|
+
bj = b[j - 1]
|
|
239
|
+
|
|
240
|
+
substitution = previous[j - 1] + self.key_dist(ai, bj)
|
|
241
|
+
deletion = previous[j] + 1.0
|
|
242
|
+
insertion = current[j - 1] + 1.0
|
|
243
|
+
current[j] = min(substitution, deletion, insertion)
|
|
244
|
+
|
|
245
|
+
previous, current = current, previous
|
|
246
|
+
|
|
247
|
+
return previous[nb]
|
|
248
|
+
|
|
249
|
+
def is_valid(self, word):
|
|
250
|
+
return is_valid(word, self.letters)
|
|
251
|
+
|
|
252
|
+
def build_query_sketch(self, qgrams):
|
|
253
|
+
sketch = HyperLogLogPlusPlus(self.cfg)
|
|
254
|
+
|
|
255
|
+
for gram in set(qgrams):
|
|
256
|
+
sketch.insert(f"feature:{gram}")
|
|
257
|
+
|
|
258
|
+
return sketch
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _score_from_sizes(intersection, left_size):
|
|
262
|
+
if left_size <= 0.0:
|
|
263
|
+
return 0.0
|
|
264
|
+
score = intersection / left_size
|
|
265
|
+
|
|
266
|
+
return min(max(score, 0.0), 1.0)
|
|
267
|
+
|
|
268
|
+
def save_dictionary(self):
|
|
269
|
+
self.t0 = time.perf_counter()
|
|
270
|
+
self.q = 2
|
|
271
|
+
self.cfg = SketchConfig(b=self.b, sparse=True)
|
|
272
|
+
self.word_sketches = []
|
|
273
|
+
self.word_estimates = []
|
|
274
|
+
|
|
275
|
+
self.qgram_word_indices = {}
|
|
276
|
+
|
|
277
|
+
# WORD_COUNT tracks physically stored entries, ACTIVE_WORD_COUNT excludes lazily removed entries. Between compactions, indices remain stable because word_dict and the parallel sketch arrays are append-only.
|
|
278
|
+
self.WORD_COUNT = len(self.word_dict)
|
|
279
|
+
self.ACTIVE_WORD_COUNT = self.WORD_COUNT - len(self.removed_words)
|
|
280
|
+
self.word_to_idx = {word: idx for idx, word in enumerate(self.word_dict)}
|
|
281
|
+
|
|
282
|
+
if self.WORD_COUNT == 0:
|
|
283
|
+
raise ValueError("Dictionary cannot be empty")
|
|
284
|
+
|
|
285
|
+
for idx, word in enumerate(self.word_dict):
|
|
286
|
+
qgrams = set(extract_qgrams(word, self.q))
|
|
287
|
+
word_sketch = HyperLogLogPlusPlus(self.cfg)
|
|
288
|
+
|
|
289
|
+
for gram in qgrams:
|
|
290
|
+
self.qgram_word_indices.setdefault(gram, []).append(idx)
|
|
291
|
+
word_sketch.insert(f"feature:{gram}")
|
|
292
|
+
|
|
293
|
+
self.word_sketches.append(word_sketch)
|
|
294
|
+
self.word_estimates.append(word_sketch.estimate())
|
|
295
|
+
|
|
296
|
+
self.preprocessing_time = time.perf_counter() - self.t0
|
|
297
|
+
|
|
298
|
+
def _append_word(self, word, display):
|
|
299
|
+
# Append one new word w/o rebuilding the dict. This is proportional to number of q-gram freatures generated from the word, i.e. O(L) for fixed q, where L = word len
|
|
300
|
+
idx = len(self.word_dict)
|
|
301
|
+
|
|
302
|
+
self.word_dict.append(word)
|
|
303
|
+
self.display_map[word] = display
|
|
304
|
+
self.word_to_idx[word] = idx
|
|
305
|
+
|
|
306
|
+
qgrams = set(extract_qgrams(word, self.q))
|
|
307
|
+
word_sketch = HyperLogLogPlusPlus(self.cfg)
|
|
308
|
+
|
|
309
|
+
for gram in qgrams:
|
|
310
|
+
self.qgram_word_indices.setdefault(gram, []).append(idx)
|
|
311
|
+
word_sketch.insert(f"feature:{gram}")
|
|
312
|
+
|
|
313
|
+
self.word_sketches.append(word_sketch)
|
|
314
|
+
self.word_estimates.append(word_sketch.estimate())
|
|
315
|
+
|
|
316
|
+
self.WORD_COUNT += 1
|
|
317
|
+
self.ACTIVE_WORD_COUNT += 1
|
|
318
|
+
|
|
319
|
+
def add_dictionary(self, to_be_added): # Overall cost is O(L), L = input len
|
|
320
|
+
words, displays = load_words(to_be_added, self.letters)
|
|
321
|
+
added = []
|
|
322
|
+
restored = []
|
|
323
|
+
|
|
324
|
+
for word in words:
|
|
325
|
+
idx = self.word_to_idx.get(word)
|
|
326
|
+
if idx is not None:
|
|
327
|
+
if word in self.removed_words:
|
|
328
|
+
self.removed_words.remove(word)
|
|
329
|
+
self.ACTIVE_WORD_COUNT += 1
|
|
330
|
+
self.display_map[word] = displays.get(word, self.display_map.get(word, word))
|
|
331
|
+
restored.append(word)
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
self._append_word(word, displays.get(word, word))
|
|
335
|
+
added.append(word)
|
|
336
|
+
|
|
337
|
+
return added + restored
|
|
338
|
+
|
|
339
|
+
def remove_dictionary(self, to_be_removed): # Lazily remove using tombstones, normal removal is O(1), O(L) amortized
|
|
340
|
+
words, _ = load_words(to_be_removed, self.letters)
|
|
341
|
+
removed = []
|
|
342
|
+
|
|
343
|
+
for word in words:
|
|
344
|
+
if word in self.word_to_idx and word not in self.removed_words:
|
|
345
|
+
self.removed_words.add(word)
|
|
346
|
+
self.ACTIVE_WORD_COUNT -= 1
|
|
347
|
+
removed.append(word)
|
|
348
|
+
|
|
349
|
+
# Rebuild only after a const frac of tombstones, so O(NL) rebuild is spread across Theta(N) deletions
|
|
350
|
+
if self.ACTIVE_WORD_COUNT > 0 and self.word_dict and len(self.removed_words) >= len(self.word_dict) * self.compact_threshold:
|
|
351
|
+
self.word_dict = [word for word in self.word_dict if word not in self.removed_words]
|
|
352
|
+
self.display_map = {word: self.display_map[word] for word in self.word_dict}
|
|
353
|
+
self.removed_words.clear()
|
|
354
|
+
self.save_dictionary()
|
|
355
|
+
|
|
356
|
+
return removed
|
|
357
|
+
|
|
358
|
+
def _candidate_indices(self, query_qgrams):
|
|
359
|
+
overlap_counts = {}
|
|
360
|
+
removed_words = self.removed_words
|
|
361
|
+
word_dict = self.word_dict
|
|
362
|
+
|
|
363
|
+
for gram in query_qgrams:
|
|
364
|
+
for idx in self.qgram_word_indices.get(gram, ()):
|
|
365
|
+
if word_dict[idx] in removed_words:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
overlap_counts[idx] = overlap_counts.get(idx, 0) + 1
|
|
369
|
+
|
|
370
|
+
candidates = list(overlap_counts.items())
|
|
371
|
+
candidates.sort(key=lambda item: (-item[1], item[0]))
|
|
372
|
+
return candidates[:self.shortlist_size]
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _is_adjacent_transposition(query, candidate):
|
|
376
|
+
if len(query) != len(candidate):
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
mismatches = [i for i, (a, b) in enumerate(zip(query, candidate)) if a != b]
|
|
380
|
+
if len(mismatches) != 2:
|
|
381
|
+
return False
|
|
382
|
+
i, j = mismatches
|
|
383
|
+
|
|
384
|
+
return j == i + 1 and query[i] == candidate[j] and query[j] == candidate[i]
|
|
385
|
+
|
|
386
|
+
def _rank_candidates(self, query, use_keyboard, print_details=False):
|
|
387
|
+
retrieval_qgrams = set(extract_qgrams(query, self.q))
|
|
388
|
+
scoring_qgrams = retrieval_qgrams
|
|
389
|
+
|
|
390
|
+
if print_details:
|
|
391
|
+
print(f"Query {query!r}: features={len(scoring_qgrams)}")
|
|
392
|
+
print("Structural q-grams: " + ", ".join(repr(gram) for gram in sorted(scoring_qgrams)))
|
|
393
|
+
|
|
394
|
+
query_sketch = self.build_query_sketch(scoring_qgrams)
|
|
395
|
+
query_estimate = query_sketch.estimate()
|
|
396
|
+
|
|
397
|
+
candidate_indices = self._candidate_indices(retrieval_qgrams)
|
|
398
|
+
retrieved_count = len(candidate_indices)
|
|
399
|
+
candidate_map = dict(candidate_indices)
|
|
400
|
+
|
|
401
|
+
# Exact-match rescue
|
|
402
|
+
idx = self.word_to_idx.get(query)
|
|
403
|
+
|
|
404
|
+
if idx is not None and query not in self.removed_words:
|
|
405
|
+
candidate_map.setdefault(idx, 0)
|
|
406
|
+
|
|
407
|
+
for i in range(len(query) - 1):
|
|
408
|
+
if query[i] == query[i + 1]:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
swapped = query[:i] + query[i + 1] + query[i] + query[i + 2:]
|
|
412
|
+
|
|
413
|
+
idx = self.word_to_idx.get(swapped)
|
|
414
|
+
if idx is not None and swapped not in self.removed_words:
|
|
415
|
+
candidate_map.setdefault(idx, 0)
|
|
416
|
+
|
|
417
|
+
candidate_indices = list(candidate_map.items())
|
|
418
|
+
|
|
419
|
+
if print_details:
|
|
420
|
+
rescued_count = len(candidate_indices) - retrieved_count
|
|
421
|
+
print(f"Candidates: {retrieved_count} retrieved, {rescued_count} transposition-rescued, {len(candidate_indices)} total")
|
|
422
|
+
|
|
423
|
+
preliminary = []
|
|
424
|
+
|
|
425
|
+
for idx, exact_overlap in candidate_indices:
|
|
426
|
+
word_sketch = self.word_sketches[idx]
|
|
427
|
+
word_estimate = self.word_estimates[idx]
|
|
428
|
+
|
|
429
|
+
structural_union = (query_sketch.union_estimate(word_sketch))
|
|
430
|
+
structural_intersection = max(0.0, query_estimate + word_estimate - structural_union)
|
|
431
|
+
structural_intersection = min(structural_intersection, query_estimate, word_estimate)
|
|
432
|
+
|
|
433
|
+
structural_score = self._score_from_sizes(intersection=structural_intersection, left_size=query_estimate)
|
|
434
|
+
candidate = self.word_dict[idx]
|
|
435
|
+
|
|
436
|
+
length_ratio = abs(len(candidate) - len(query)) / max(len(query), 1)
|
|
437
|
+
length_score = max(0.0, 1.0 - length_ratio**2)
|
|
438
|
+
exact_bonus = 1.0 if query == candidate else 0.0
|
|
439
|
+
|
|
440
|
+
preliminary_score = structural_score * length_score + exact_bonus
|
|
441
|
+
preliminary.append((preliminary_score, idx, structural_score, exact_overlap, length_score, word_estimate, structural_union, structural_intersection))
|
|
442
|
+
|
|
443
|
+
preliminary.sort(key=lambda item: (-item[0], item[1]))
|
|
444
|
+
|
|
445
|
+
keyboard_limit = min(self.keyboard_shortlist_size, len(preliminary))
|
|
446
|
+
|
|
447
|
+
ranked = []
|
|
448
|
+
|
|
449
|
+
for position, (preliminary_score, idx, structural_score, exact_overlap, length_score, word_estimate, structural_union, structural_intersection) in enumerate(preliminary):
|
|
450
|
+
candidate = self.word_dict[idx]
|
|
451
|
+
|
|
452
|
+
if use_keyboard and position < keyboard_limit:
|
|
453
|
+
keyboard_score = 1.0 / (1.0 + self.word_dist(query, self.word_dict[idx]))
|
|
454
|
+
else:
|
|
455
|
+
keyboard_score = 0.0
|
|
456
|
+
|
|
457
|
+
exact_bonus = 1.0 if query == self.word_dict[idx] else 0.0
|
|
458
|
+
transposition_bonus = self.transposition_bonus if self._is_adjacent_transposition(query, candidate) else 0.0
|
|
459
|
+
|
|
460
|
+
score = structural_score * length_score + self.beta * keyboard_score + transposition_bonus + exact_bonus
|
|
461
|
+
|
|
462
|
+
ranked.append((score, idx, structural_score, exact_overlap, length_score, keyboard_score, transposition_bonus, exact_bonus, word_estimate, structural_union, structural_intersection))
|
|
463
|
+
|
|
464
|
+
ranked.sort(key=lambda item: (-item[0], item[1]))
|
|
465
|
+
|
|
466
|
+
if print_details:
|
|
467
|
+
print("Top ranked candidates:")
|
|
468
|
+
|
|
469
|
+
for (score, idx, structural_score, exact_overlap, length_score, keyboard_score, transposition_bonus, exact_bonus, word_estimate, structural_union, structural_intersection) in ranked[:5]:
|
|
470
|
+
candidate = self.word_dict[idx]
|
|
471
|
+
print(f" {candidate!r}:")
|
|
472
|
+
print(f" retrieval overlap: {exact_overlap}")
|
|
473
|
+
print(f" HLL++ normal: query={query_estimate:.6f}, candidate={word_estimate:.6f}, union={structural_union:.6f}, intersection={structural_intersection:.6f}")
|
|
474
|
+
print(f" structural: containment={structural_score:.6f}")
|
|
475
|
+
|
|
476
|
+
structural_contribution = structural_score * length_score
|
|
477
|
+
keyboard_contribution = self.beta * keyboard_score
|
|
478
|
+
print(f" modifiers: length={length_score:.6f}, keyboard={keyboard_score:.6f}, transpose_bonus={transposition_bonus:.3f}, exact_bonus={exact_bonus:.1f}")
|
|
479
|
+
print(f" contributions: structural*length={structural_contribution:.6f}, beta*keyboard={keyboard_contribution:.6f}")
|
|
480
|
+
print(f" final score: {score:.6f}")
|
|
481
|
+
|
|
482
|
+
return ranked
|
|
483
|
+
|
|
484
|
+
def autocorrect(self, queries_list, output_file="None", use_keyboard=True, return_invalid_words=True, print_details=False, print_times=False):
|
|
485
|
+
if print_times:
|
|
486
|
+
self.save_dictionary()
|
|
487
|
+
|
|
488
|
+
queries = load_queries(queries_list)
|
|
489
|
+
|
|
490
|
+
self.t2 = time.perf_counter()
|
|
491
|
+
|
|
492
|
+
output = []
|
|
493
|
+
suggestions = {}
|
|
494
|
+
final_scores = {}
|
|
495
|
+
|
|
496
|
+
for query_display, query in queries:
|
|
497
|
+
if not self.is_valid(query):
|
|
498
|
+
replacement = query_display if return_invalid_words else ""
|
|
499
|
+
suggestions[query_display] = replacement
|
|
500
|
+
final_scores[query_display] = 0.0
|
|
501
|
+
output.append(replacement)
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
ranked = self._rank_candidates(query, use_keyboard=use_keyboard, print_details=print_details)
|
|
505
|
+
|
|
506
|
+
if not ranked:
|
|
507
|
+
replacement = query_display if return_invalid_words else ""
|
|
508
|
+
suggestions[query_display] = replacement
|
|
509
|
+
final_scores[query_display] = 0.0
|
|
510
|
+
output.append(replacement)
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
(best_score, best_idx, best_structural_score, best_exact_overlap, *_) = ranked[0]
|
|
514
|
+
|
|
515
|
+
picked = self.word_dict[best_idx]
|
|
516
|
+
displayed_picked = self.display_map.get(picked, picked)
|
|
517
|
+
|
|
518
|
+
if print_details:
|
|
519
|
+
print(f"Selected {displayed_picked!r} for {query_display!r}: final={best_score:.6f}, containment={best_structural_score:.6f}, retrieval_overlap={best_exact_overlap}")
|
|
520
|
+
print("-" * 60)
|
|
521
|
+
|
|
522
|
+
suggestions[query_display] = displayed_picked
|
|
523
|
+
final_scores[query_display] = best_score
|
|
524
|
+
output.append(displayed_picked)
|
|
525
|
+
|
|
526
|
+
self.t3 = time.perf_counter()
|
|
527
|
+
|
|
528
|
+
if output_file != "None":
|
|
529
|
+
with open(output_file, "w", encoding="utf-8") as output_stream:
|
|
530
|
+
output_stream.write("\n".join(output))
|
|
531
|
+
|
|
532
|
+
if print_times:
|
|
533
|
+
print(f"Dictionary preprocessing: {self.preprocessing_time:.3f}s")
|
|
534
|
+
print(f"Current query batch: {self.t3 - self.t2:.3f}s")
|
|
535
|
+
print(f"Total autocorrect: {self.preprocessing_time + self.t3 - self.t2:.3f}s")
|
|
536
|
+
|
|
537
|
+
return Results(suggestions=suggestions, scores=final_scores)
|
|
538
|
+
|
|
539
|
+
def top_k(self, queries_list, k, output_file="None", use_keyboard=True, return_invalid_words=True, print_details=False, print_times=False):
|
|
540
|
+
if not isinstance(k, int) or isinstance(k, bool):
|
|
541
|
+
raise TypeError("`k` must be an integer")
|
|
542
|
+
|
|
543
|
+
if k <= 0:
|
|
544
|
+
raise ValueError("`k` must be positive")
|
|
545
|
+
|
|
546
|
+
if print_times:
|
|
547
|
+
self.save_dictionary()
|
|
548
|
+
|
|
549
|
+
queries = load_queries(queries_list)
|
|
550
|
+
|
|
551
|
+
self.t2 = time.perf_counter()
|
|
552
|
+
|
|
553
|
+
output = []
|
|
554
|
+
suggestions = {}
|
|
555
|
+
final_scores = {}
|
|
556
|
+
|
|
557
|
+
for query_display, query in queries:
|
|
558
|
+
if not self.is_valid(query):
|
|
559
|
+
if return_invalid_words:
|
|
560
|
+
top_words = [query_display]
|
|
561
|
+
else:
|
|
562
|
+
top_words = []
|
|
563
|
+
|
|
564
|
+
while len(top_words) < k:
|
|
565
|
+
top_words.append("")
|
|
566
|
+
|
|
567
|
+
top_scores = [0.0] * k
|
|
568
|
+
|
|
569
|
+
suggestions[query_display] = top_words
|
|
570
|
+
final_scores[query_display] = top_scores
|
|
571
|
+
output.append(" ".join(top_words))
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
ranked = self._rank_candidates(query, use_keyboard=use_keyboard, print_details=print_details)
|
|
575
|
+
|
|
576
|
+
seen = set()
|
|
577
|
+
top_words = []
|
|
578
|
+
top_scores = []
|
|
579
|
+
|
|
580
|
+
for (score, idx, *_) in ranked:
|
|
581
|
+
suggestion = self.display_map.get(self.word_dict[idx], self.word_dict[idx])
|
|
582
|
+
|
|
583
|
+
if suggestion in seen:
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
seen.add(suggestion)
|
|
587
|
+
top_words.append(suggestion)
|
|
588
|
+
top_scores.append(score)
|
|
589
|
+
|
|
590
|
+
if len(top_words) == k:
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
if not top_words and return_invalid_words:
|
|
594
|
+
top_words.append(query_display)
|
|
595
|
+
top_scores.append(0.0)
|
|
596
|
+
|
|
597
|
+
while len(top_words) < k:
|
|
598
|
+
top_words.append("")
|
|
599
|
+
top_scores.append(0.0)
|
|
600
|
+
|
|
601
|
+
if print_details:
|
|
602
|
+
displayed_results = [f"{word!r} ({score:.6f})" for word, score in zip(top_words, top_scores) if word]
|
|
603
|
+
print(f"Selected top {k} for {query_display!r}: {', '.join(displayed_results)}")
|
|
604
|
+
print("-" * 60)
|
|
605
|
+
|
|
606
|
+
suggestions[query_display] = top_words
|
|
607
|
+
final_scores[query_display] = top_scores
|
|
608
|
+
output.append(" ".join(top_words))
|
|
609
|
+
|
|
610
|
+
self.t3 = time.perf_counter()
|
|
611
|
+
|
|
612
|
+
if output_file != "None":
|
|
613
|
+
with open(output_file, "w", encoding="utf-8") as output_stream:
|
|
614
|
+
output_stream.write("\n".join(output))
|
|
615
|
+
|
|
616
|
+
if print_times:
|
|
617
|
+
print(f"Dictionary preprocessing: {self.preprocessing_time:.3f}s")
|
|
618
|
+
print(f"Current query batch: {self.t3 - self.t2:.3f}s")
|
|
619
|
+
print(f"Total top-{k}: {self.preprocessing_time + self.t3 - self.t2:.3f}s")
|
|
620
|
+
|
|
621
|
+
return Results(suggestions=suggestions, scores=final_scores)
|
|
622
|
+
|
|
623
|
+
def top3(self, queries_list, output_file="None", use_keyboard=True, return_invalid_words=True, print_details=False, print_times=False):
|
|
624
|
+
return self.top_k(queries_list=queries_list, k=3, output_file=output_file, use_keyboard=use_keyboard, return_invalid_words=return_invalid_words, print_details=print_details, print_times=print_times)
|
|
625
|
+
|
|
626
|
+
# ======== SAMPLE USAGE ======== #
|
|
627
|
+
if __name__ == "__main__":
|
|
628
|
+
ac = Autocorrector()
|
|
629
|
+
|
|
630
|
+
# File
|
|
631
|
+
ans1 = ac.autocorrect("test_files/typo_file.txt", "outputs/class_suggestions.txt")
|
|
632
|
+
print(ans1.suggestions)
|
|
633
|
+
print(ans1.scores)
|
|
634
|
+
|
|
635
|
+
ans2 = ac.top3("test_files/typo_file.txt", "outputs/class_suggestions.txt")
|
|
636
|
+
|
|
637
|
+
# Or even top 5
|
|
638
|
+
ans2_top5 = ac.top_k("test_files/typo_file.txt", 5, "outputs/class_suggestions.txt")
|
|
639
|
+
|
|
640
|
+
# Optionally, you can not want it to output it into a file, then:
|
|
641
|
+
# Individual strings
|
|
642
|
+
ans3 = ac.autocorrect("hillo")
|
|
643
|
+
ans4 = ac.top3("hillo")
|
|
644
|
+
ans4_top5 = ac.top_k("hillo", 5)
|
|
645
|
+
|
|
646
|
+
# Arrays
|
|
647
|
+
ans5 = ac.autocorrect(["tsetign", "hillo", "goobye", "haedhpoesn"])
|
|
648
|
+
ans6 = ac.top3(["tsetign", "hillo", "goobye", "haedhpoesn"])
|
|
649
|
+
|
|
650
|
+
# You can even have a custom dictionary!
|
|
651
|
+
dictionary = ["apple", "banana", "grape", "orange"]
|
|
652
|
+
custom_ac = Autocorrector(dictionary)
|
|
653
|
+
|
|
654
|
+
ans7 = custom_ac.autocorrect(["applle", "banana", "banan", "orenge", "grap", "pineapple"])
|
|
655
|
+
ans8 = custom_ac.top3(["applle", "banana", "banan", "orenge", "grap", "pineapple"])
|
|
656
|
+
|
|
657
|
+
print(ans7.suggestions)
|
|
658
|
+
print(ans8.suggestions)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
############################################
|
|
2
|
+
# Copyright (c) 2026 Shun/修海 (@shun4midx) #
|
|
3
|
+
# Project: HyperLogLogPlusPlus-Autocorrect #
|
|
4
|
+
# File Type: Python file #
|
|
5
|
+
# File: Hasher.py #
|
|
6
|
+
############################################
|
|
7
|
+
|
|
8
|
+
import struct
|
|
9
|
+
|
|
10
|
+
def murmur3_64(key: str, seed: int = 42) -> int:
|
|
11
|
+
key_bytes = key.encode('utf-8')
|
|
12
|
+
length = len(key_bytes)
|
|
13
|
+
m = 0xc6a4a7935bd1e995
|
|
14
|
+
r = 47
|
|
15
|
+
|
|
16
|
+
h = seed ^ (length * m)
|
|
17
|
+
|
|
18
|
+
num_blocks = length // 8
|
|
19
|
+
for i in range(num_blocks):
|
|
20
|
+
k = struct.unpack_from('<Q', key_bytes, i * 8)[0] # Little-endian 64-bit
|
|
21
|
+
k *= m
|
|
22
|
+
k ^= k >> r
|
|
23
|
+
k *= m
|
|
24
|
+
|
|
25
|
+
h ^= k
|
|
26
|
+
h *= m
|
|
27
|
+
|
|
28
|
+
remaining = key_bytes[num_blocks * 8:]
|
|
29
|
+
remaining_val = 0
|
|
30
|
+
for i in range(len(remaining)):
|
|
31
|
+
remaining_val |= remaining[i] << (i * 8)
|
|
32
|
+
if remaining:
|
|
33
|
+
h ^= remaining_val
|
|
34
|
+
h *= m
|
|
35
|
+
|
|
36
|
+
h ^= h >> r
|
|
37
|
+
h *= m
|
|
38
|
+
h ^= h >> r
|
|
39
|
+
|
|
40
|
+
return h & 0xFFFFFFFFFFFFFFFF # Return as unsigned 64-bit
|
|
41
|
+
|
|
42
|
+
def str_to_u64(s: str) -> int:
|
|
43
|
+
return murmur3_64(s)
|