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.
@@ -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)