stride-align 0.6.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 (190) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/NOTICE +255 -0
  4. data/README.md +107 -0
  5. data/data/bmpm_data/gen_approx_any.txt +131 -0
  6. data/data/bmpm_data/gen_approx_arabic.txt +26 -0
  7. data/data/bmpm_data/gen_approx_common.txt +233 -0
  8. data/data/bmpm_data/gen_approx_cyrillic.txt +18 -0
  9. data/data/bmpm_data/gen_approx_czech.txt +18 -0
  10. data/data/bmpm_data/gen_approx_dutch.txt +18 -0
  11. data/data/bmpm_data/gen_approx_english.txt +47 -0
  12. data/data/bmpm_data/gen_approx_french.txt +25 -0
  13. data/data/bmpm_data/gen_approx_german.txt +73 -0
  14. data/data/bmpm_data/gen_approx_greek.txt +18 -0
  15. data/data/bmpm_data/gen_approx_greeklatin.txt +20 -0
  16. data/data/bmpm_data/gen_approx_hebrew.txt +18 -0
  17. data/data/bmpm_data/gen_approx_hungarian.txt +18 -0
  18. data/data/bmpm_data/gen_approx_italian.txt +18 -0
  19. data/data/bmpm_data/gen_approx_polish.txt +84 -0
  20. data/data/bmpm_data/gen_approx_portuguese.txt +18 -0
  21. data/data/bmpm_data/gen_approx_romanian.txt +18 -0
  22. data/data/bmpm_data/gen_approx_russian.txt +48 -0
  23. data/data/bmpm_data/gen_approx_spanish.txt +21 -0
  24. data/data/bmpm_data/gen_approx_turkish.txt +18 -0
  25. data/data/bmpm_data/gen_exact_any.txt +40 -0
  26. data/data/bmpm_data/gen_exact_approx_common.txt +79 -0
  27. data/data/bmpm_data/gen_exact_arabic.txt +18 -0
  28. data/data/bmpm_data/gen_exact_common.txt +32 -0
  29. data/data/bmpm_data/gen_exact_cyrillic.txt +18 -0
  30. data/data/bmpm_data/gen_exact_czech.txt +18 -0
  31. data/data/bmpm_data/gen_exact_dutch.txt +18 -0
  32. data/data/bmpm_data/gen_exact_english.txt +18 -0
  33. data/data/bmpm_data/gen_exact_french.txt +18 -0
  34. data/data/bmpm_data/gen_exact_german.txt +18 -0
  35. data/data/bmpm_data/gen_exact_greek.txt +18 -0
  36. data/data/bmpm_data/gen_exact_greeklatin.txt +18 -0
  37. data/data/bmpm_data/gen_exact_hebrew.txt +18 -0
  38. data/data/bmpm_data/gen_exact_hungarian.txt +18 -0
  39. data/data/bmpm_data/gen_exact_italian.txt +18 -0
  40. data/data/bmpm_data/gen_exact_polish.txt +23 -0
  41. data/data/bmpm_data/gen_exact_portuguese.txt +18 -0
  42. data/data/bmpm_data/gen_exact_romanian.txt +18 -0
  43. data/data/bmpm_data/gen_exact_russian.txt +19 -0
  44. data/data/bmpm_data/gen_exact_spanish.txt +19 -0
  45. data/data/bmpm_data/gen_exact_turkish.txt +18 -0
  46. data/data/bmpm_data/gen_hebrew_common.txt +113 -0
  47. data/data/bmpm_data/gen_lang.txt +295 -0
  48. data/data/bmpm_data/gen_languages.txt +36 -0
  49. data/data/bmpm_data/gen_rules_any.txt +367 -0
  50. data/data/bmpm_data/gen_rules_arabic.txt +76 -0
  51. data/data/bmpm_data/gen_rules_cyrillic.txt +99 -0
  52. data/data/bmpm_data/gen_rules_czech.txt +67 -0
  53. data/data/bmpm_data/gen_rules_dutch.txt +78 -0
  54. data/data/bmpm_data/gen_rules_english.txt +113 -0
  55. data/data/bmpm_data/gen_rules_french.txt +114 -0
  56. data/data/bmpm_data/gen_rules_german.txt +129 -0
  57. data/data/bmpm_data/gen_rules_greek.txt +97 -0
  58. data/data/bmpm_data/gen_rules_greeklatin.txt +118 -0
  59. data/data/bmpm_data/gen_rules_hebrew.txt +62 -0
  60. data/data/bmpm_data/gen_rules_hungarian.txt +83 -0
  61. data/data/bmpm_data/gen_rules_italian.txt +77 -0
  62. data/data/bmpm_data/gen_rules_polish.txt +185 -0
  63. data/data/bmpm_data/gen_rules_portuguese.txt +105 -0
  64. data/data/bmpm_data/gen_rules_romanian.txt +64 -0
  65. data/data/bmpm_data/gen_rules_russian.txt +142 -0
  66. data/data/bmpm_data/gen_rules_spanish.txt +85 -0
  67. data/data/bmpm_data/gen_rules_turkish.txt +50 -0
  68. data/data/keyboard_data/qwerty.npy +0 -0
  69. data/data/matrix_data/BLOSUM100 +31 -0
  70. data/data/matrix_data/BLOSUM30 +31 -0
  71. data/data/matrix_data/BLOSUM35 +31 -0
  72. data/data/matrix_data/BLOSUM40 +31 -0
  73. data/data/matrix_data/BLOSUM45 +25 -0
  74. data/data/matrix_data/BLOSUM50 +25 -0
  75. data/data/matrix_data/BLOSUM55 +31 -0
  76. data/data/matrix_data/BLOSUM60 +31 -0
  77. data/data/matrix_data/BLOSUM62 +25 -0
  78. data/data/matrix_data/BLOSUM65 +31 -0
  79. data/data/matrix_data/BLOSUM70 +31 -0
  80. data/data/matrix_data/BLOSUM75 +31 -0
  81. data/data/matrix_data/BLOSUM80 +25 -0
  82. data/data/matrix_data/BLOSUM85 +31 -0
  83. data/data/matrix_data/BLOSUM90 +25 -0
  84. data/data/matrix_data/NUC.4.4 +25 -0
  85. data/data/matrix_data/PAM10 +34 -0
  86. data/data/matrix_data/PAM100 +34 -0
  87. data/data/matrix_data/PAM110 +34 -0
  88. data/data/matrix_data/PAM120 +34 -0
  89. data/data/matrix_data/PAM130 +34 -0
  90. data/data/matrix_data/PAM140 +34 -0
  91. data/data/matrix_data/PAM150 +34 -0
  92. data/data/matrix_data/PAM160 +34 -0
  93. data/data/matrix_data/PAM170 +34 -0
  94. data/data/matrix_data/PAM180 +34 -0
  95. data/data/matrix_data/PAM190 +34 -0
  96. data/data/matrix_data/PAM20 +34 -0
  97. data/data/matrix_data/PAM200 +34 -0
  98. data/data/matrix_data/PAM210 +34 -0
  99. data/data/matrix_data/PAM220 +34 -0
  100. data/data/matrix_data/PAM230 +34 -0
  101. data/data/matrix_data/PAM240 +34 -0
  102. data/data/matrix_data/PAM250 +25 -0
  103. data/data/matrix_data/PAM260 +34 -0
  104. data/data/matrix_data/PAM270 +34 -0
  105. data/data/matrix_data/PAM280 +34 -0
  106. data/data/matrix_data/PAM290 +34 -0
  107. data/data/matrix_data/PAM30 +25 -0
  108. data/data/matrix_data/PAM300 +34 -0
  109. data/data/matrix_data/PAM310 +34 -0
  110. data/data/matrix_data/PAM320 +34 -0
  111. data/data/matrix_data/PAM330 +34 -0
  112. data/data/matrix_data/PAM340 +34 -0
  113. data/data/matrix_data/PAM350 +34 -0
  114. data/data/matrix_data/PAM360 +34 -0
  115. data/data/matrix_data/PAM370 +34 -0
  116. data/data/matrix_data/PAM380 +34 -0
  117. data/data/matrix_data/PAM390 +34 -0
  118. data/data/matrix_data/PAM40 +34 -0
  119. data/data/matrix_data/PAM400 +34 -0
  120. data/data/matrix_data/PAM410 +34 -0
  121. data/data/matrix_data/PAM420 +34 -0
  122. data/data/matrix_data/PAM430 +34 -0
  123. data/data/matrix_data/PAM440 +34 -0
  124. data/data/matrix_data/PAM450 +34 -0
  125. data/data/matrix_data/PAM460 +34 -0
  126. data/data/matrix_data/PAM470 +34 -0
  127. data/data/matrix_data/PAM480 +34 -0
  128. data/data/matrix_data/PAM490 +34 -0
  129. data/data/matrix_data/PAM50 +34 -0
  130. data/data/matrix_data/PAM500 +34 -0
  131. data/data/matrix_data/PAM60 +34 -0
  132. data/data/matrix_data/PAM70 +25 -0
  133. data/data/matrix_data/PAM80 +34 -0
  134. data/data/matrix_data/PAM90 +34 -0
  135. data/ext/stride_align/backend_avx2.cpp +2 -0
  136. data/ext/stride_align/backend_avx512bwvl.cpp +2 -0
  137. data/ext/stride_align/backend_generic.cpp +3 -0
  138. data/ext/stride_align/backend_impl.hpp +983 -0
  139. data/ext/stride_align/backend_lasx.cpp +2 -0
  140. data/ext/stride_align/backend_lsx.cpp +2 -0
  141. data/ext/stride_align/backend_neon.cpp +2 -0
  142. data/ext/stride_align/backend_rvv.cpp +2 -0
  143. data/ext/stride_align/backend_sse41.cpp +2 -0
  144. data/ext/stride_align/backend_sve.cpp +2 -0
  145. data/ext/stride_align/backend_sve2.cpp +2 -0
  146. data/ext/stride_align/backend_vsx.cpp +2 -0
  147. data/ext/stride_align/beider_morse_impl.cpp +5 -0
  148. data/ext/stride_align/cpu_detect.cpp +172 -0
  149. data/ext/stride_align/cpu_detect.hpp +6 -0
  150. data/ext/stride_align/extconf.rb +114 -0
  151. data/ext/stride_align/target_profile.hpp +82 -0
  152. data/ext/stride_align/vendor/beider_morse_impl.cpp +1467 -0
  153. data/ext/stride_align/vendor/stride_align/alignment.hpp +199 -0
  154. data/ext/stride_align/vendor/stride_align/batch.hpp +812 -0
  155. data/ext/stride_align/vendor/stride_align/beider_morse.hpp +121 -0
  156. data/ext/stride_align/vendor/stride_align/caverphone.hpp +222 -0
  157. data/ext/stride_align/vendor/stride_align/cologne_phonetic.hpp +202 -0
  158. data/ext/stride_align/vendor/stride_align/core.hpp +731 -0
  159. data/ext/stride_align/vendor/stride_align/daitch_mokotoff.hpp +631 -0
  160. data/ext/stride_align/vendor/stride_align/double_metaphone.hpp +796 -0
  161. data/ext/stride_align/vendor/stride_align/dtw.hpp +300 -0
  162. data/ext/stride_align/vendor/stride_align/encoded.hpp +235 -0
  163. data/ext/stride_align/vendor/stride_align/hamming.hpp +55 -0
  164. data/ext/stride_align/vendor/stride_align/indel.hpp +1200 -0
  165. data/ext/stride_align/vendor/stride_align/jaro.hpp +517 -0
  166. data/ext/stride_align/vendor/stride_align/lcs.hpp +159 -0
  167. data/ext/stride_align/vendor/stride_align/levenshtein.hpp +1247 -0
  168. data/ext/stride_align/vendor/stride_align/levenshtein_prepared.hpp +193 -0
  169. data/ext/stride_align/vendor/stride_align/match_rating.hpp +168 -0
  170. data/ext/stride_align/vendor/stride_align/metaphone.hpp +291 -0
  171. data/ext/stride_align/vendor/stride_align/ngram.hpp +176 -0
  172. data/ext/stride_align/vendor/stride_align/nysiis.hpp +199 -0
  173. data/ext/stride_align/vendor/stride_align/pairwise_alignment.hpp +465 -0
  174. data/ext/stride_align/vendor/stride_align/partial_ratio.hpp +486 -0
  175. data/ext/stride_align/vendor/stride_align/ratcliff_obershelp.hpp +101 -0
  176. data/ext/stride_align/vendor/stride_align/soundex.hpp +108 -0
  177. data/ext/stride_align/vendor/stride_align/token_ratios.hpp +445 -0
  178. data/ext/stride_align/vendor/stride_align/types.hpp +16 -0
  179. data/ext/stride_align/vendor/stride_align/utf8.hpp +512 -0
  180. data/ext/stride_align/vendor/stride_align/wratio.hpp +363 -0
  181. data/lib/stride_align/algorithms.rb +296 -0
  182. data/lib/stride_align/alignment_path.rb +217 -0
  183. data/lib/stride_align/backend.rb +47 -0
  184. data/lib/stride_align/batch.rb +705 -0
  185. data/lib/stride_align/core.rb +180 -0
  186. data/lib/stride_align/keyboard.rb +200 -0
  187. data/lib/stride_align/matrices.rb +403 -0
  188. data/lib/stride_align/version.rb +5 -0
  189. data/lib/stride_align.rb +87 -0
  190. metadata +231 -0
@@ -0,0 +1,193 @@
1
+ #pragma once
2
+
3
+ // Persistent prepared state for Hyyrö's bit-parallel Levenshtein
4
+ // (Myers' algorithm). Pre-builds the pattern_match (PEQ) table once
5
+ // per query so each ``distance(target)`` call skips the per-call PEQ
6
+ // rebuild — the dominant non-kernel cost for the streaming-against-
7
+ // fixed-query workload.
8
+ //
9
+ // Today only the bytes / 1-byte unicode fast path has a prepared
10
+ // variant; wider tokens still go through the per-call hash-map PEQ
11
+ // build inside ``myers_distance<Token>``. Adding a wide prepared
12
+ // state is straightforward (cache the ``unordered_map<Token,
13
+ // vector<uint64_t>>`` instead of the flat 256*B vector) but isn't
14
+ // required to fix the original ``1v1`` bench bug.
15
+
16
+ #include <algorithm>
17
+ #include <cstddef>
18
+ #include <cstdint>
19
+ #include <span>
20
+ #include <vector>
21
+
22
+ #include "stride_align/levenshtein.hpp"
23
+
24
+ namespace stride_align::levenshtein {
25
+
26
+ struct PreparedLevenshteinScoreU8 {
27
+ // Owning copy of the query bytes. We keep it so callers can
28
+ // reflect/debug the prepared state and so the kernel can verify
29
+ // text length math (mirrors the existing kernels' use of m).
30
+ std::vector<std::uint8_t> pattern;
31
+ // m = |pattern|. 0 means empty-query (returns |text|).
32
+ std::size_t m = 0;
33
+ // B = ceil(m / 64), the number of 64-bit blocks the PEQ spans.
34
+ // Always 1 in the single_word case (m <= 64); m/64 rounded up
35
+ // in the multi-word case.
36
+ std::size_t B = 0;
37
+ // single_word == true when m <= 64 and the single-word kernel
38
+ // applies. PEQ is then 256 uint64s (one per byte value) with the
39
+ // pattern positions bit-packed into the single word.
40
+ // Otherwise the PEQ is 256*B uint64s laid out [c*B + block].
41
+ bool single_word = false;
42
+ // Bit-vector PEQ. Indexed by byte value (0..255) for the single-
43
+ // word case, and by (byte * B + block_index) for the multi-word
44
+ // case.
45
+ std::vector<std::uint64_t> peq;
46
+ // Multi-word only: the top bit of the last block (m mod 64
47
+ // accounting). Mirrors the value computed inline in
48
+ // myers_multi_word_u8.
49
+ std::uint64_t top_bit_last = 0;
50
+ };
51
+
52
+ inline PreparedLevenshteinScoreU8 prepare_levenshtein_score_u8(
53
+ std::span<const std::uint8_t> pattern) {
54
+ PreparedLevenshteinScoreU8 prepared;
55
+ prepared.pattern.assign(pattern.begin(), pattern.end());
56
+ prepared.m = pattern.size();
57
+ if (prepared.m == 0) {
58
+ return prepared;
59
+ }
60
+ constexpr std::size_t kWord = 64U;
61
+ const std::uint64_t one = 1;
62
+ if (prepared.m <= kWord) {
63
+ prepared.single_word = true;
64
+ prepared.B = 1;
65
+ prepared.peq.assign(256, 0);
66
+ for (std::size_t i = 0; i < prepared.m; ++i) {
67
+ prepared.peq[pattern[i]] |= one << i;
68
+ }
69
+ } else {
70
+ prepared.single_word = false;
71
+ prepared.B = (prepared.m + kWord - 1U) / kWord;
72
+ prepared.peq.assign(static_cast<std::size_t>(256) * prepared.B, 0);
73
+ for (std::size_t i = 0; i < prepared.m; ++i) {
74
+ prepared.peq[static_cast<std::size_t>(pattern[i]) * prepared.B + (i / kWord)] |=
75
+ one << (i % kWord);
76
+ }
77
+ const std::size_t last_bits = prepared.m - (prepared.B - 1U) * kWord;
78
+ prepared.top_bit_last = std::uint64_t{1} << (last_bits - 1U);
79
+ }
80
+ return prepared;
81
+ }
82
+
83
+ // Single-word kernel running against a pre-built PEQ. Mirrors the
84
+ // inner loop of ``myers_single_word_u8`` exactly; only the PEQ build
85
+ // is hoisted out.
86
+ inline std::size_t myers_single_word_u8_with_peq(
87
+ const std::uint64_t* peq,
88
+ std::size_t m,
89
+ std::span<const std::uint8_t> text,
90
+ std::size_t cutoff = kNoCutoff) noexcept {
91
+ const std::uint64_t one = 1;
92
+ const std::size_t n = text.size();
93
+ if (m == 0U) return n;
94
+ if (n == 0U) return m;
95
+ if (cutoff != kNoCutoff) {
96
+ const std::size_t len_diff = m > n ? m - n : n - m;
97
+ if (len_diff > cutoff) return cutoff + 1U;
98
+ }
99
+
100
+ const std::size_t shift = m - 1U;
101
+ std::uint64_t vp = (m == 64U) ? ~std::uint64_t{0} : ((one << m) - 1);
102
+ std::uint64_t vn = 0;
103
+ std::size_t score = m;
104
+
105
+ if (cutoff == kNoCutoff) {
106
+ for (const std::uint8_t c : text) {
107
+ const std::uint64_t eq = peq[c];
108
+ const std::uint64_t x = eq | vn;
109
+ const std::uint64_t d0 = (((x & vp) + vp) ^ vp) | x;
110
+ const std::uint64_t hp = vn | ~(d0 | vp);
111
+ const std::uint64_t hn = d0 & vp;
112
+ score += static_cast<std::size_t>((hp >> shift) & 1U);
113
+ score -= static_cast<std::size_t>((hn >> shift) & 1U);
114
+ const std::uint64_t hp_shift = (hp << 1) | one;
115
+ const std::uint64_t hn_shift = (hn << 1);
116
+ vp = hn_shift | ~(d0 | hp_shift);
117
+ vn = d0 & hp_shift;
118
+ }
119
+ return score;
120
+ }
121
+
122
+ std::size_t k = 0;
123
+ for (const std::uint8_t c : text) {
124
+ const std::uint64_t eq = peq[c];
125
+ const std::uint64_t x = eq | vn;
126
+ const std::uint64_t d0 = (((x & vp) + vp) ^ vp) | x;
127
+ const std::uint64_t hp = vn | ~(d0 | vp);
128
+ const std::uint64_t hn = d0 & vp;
129
+ score += static_cast<std::size_t>((hp >> shift) & 1U);
130
+ score -= static_cast<std::size_t>((hn >> shift) & 1U);
131
+ const std::uint64_t hp_shift = (hp << 1) | one;
132
+ const std::uint64_t hn_shift = (hn << 1);
133
+ vp = hn_shift | ~(d0 | hp_shift);
134
+ vn = d0 & hp_shift;
135
+ ++k;
136
+ if (score > cutoff + (n - k)) {
137
+ return cutoff + 1U;
138
+ }
139
+ }
140
+ return score;
141
+ }
142
+
143
+ inline std::size_t levenshtein_score_prepared_u8(
144
+ const PreparedLevenshteinScoreU8& prepared,
145
+ std::span<const std::uint8_t> text,
146
+ std::size_t cutoff = kNoCutoff) {
147
+ if (prepared.m == 0) {
148
+ return text.size();
149
+ }
150
+ if (text.empty()) {
151
+ return prepared.m;
152
+ }
153
+ if (cutoff != kNoCutoff) {
154
+ const std::size_t n = text.size();
155
+ const std::size_t len_diff =
156
+ prepared.m > n ? prepared.m - n : n - prepared.m;
157
+ if (len_diff > cutoff) {
158
+ return cutoff + 1U;
159
+ }
160
+ }
161
+ if (prepared.single_word) {
162
+ return myers_single_word_u8_with_peq(
163
+ prepared.peq.data(), prepared.m, text, cutoff);
164
+ }
165
+ // Multi-word: reuse thread-local vp/vn scratch (amortised across
166
+ // prepared 1-vs-N calls) and the existing detail::myers_inner with
167
+ // our cached PEQ.
168
+ const std::size_t B = prepared.B;
169
+ MultiWordLevScratch& scr = multi_word_lev_scratch();
170
+ scr.resize_for(B);
171
+ std::fill_n(scr.vp.data(), B, ~std::uint64_t{0});
172
+ std::fill_n(scr.vn.data(), B, std::uint64_t{0});
173
+ constexpr std::size_t kWord = 64U;
174
+ const std::size_t last_bits = prepared.m - (B - 1U) * kWord;
175
+ if (last_bits < kWord) {
176
+ scr.vp[B - 1U] = (std::uint64_t{1} << last_bits) - 1U;
177
+ }
178
+ return detail::myers_inner(
179
+ prepared.m,
180
+ B,
181
+ text,
182
+ [&prepared, B](std::uint8_t c) {
183
+ return std::span<const std::uint64_t>(
184
+ prepared.peq.data() + static_cast<std::size_t>(c) * B, B);
185
+ },
186
+ scr.vp.data(),
187
+ scr.vn.data(),
188
+ prepared.top_bit_last,
189
+ prepared.m,
190
+ cutoff);
191
+ }
192
+
193
+ } // namespace stride_align::levenshtein
@@ -0,0 +1,168 @@
1
+ #pragma once
2
+
3
+ // Match Rating Approach (Moore, Western Airlines, 1977).
4
+ //
5
+ // Two operations:
6
+ //
7
+ // 1. ``match_rating_codex(name)`` — encode a name into a short
8
+ // phonetic code (typically 3-6 ASCII letters).
9
+ // 2. ``match_rating_compare(name1, name2)`` — return ``true`` if
10
+ // the pair is similar enough per MRA's matching rules.
11
+ //
12
+ // MRA pairs an encoder and a rule-based comparator rather than
13
+ // being a one-shot distance. Two names produce the same codex
14
+ // (and so trivially match) when their consonant skeleton is the
15
+ // same; the comparator also tolerates one of the two having a
16
+ // few more characters by enforcing a length-dependent minimum
17
+ // "rating".
18
+ //
19
+ // Codex rules:
20
+ // * Drop non-ASCII letters; upper-case the rest.
21
+ // * Drop vowels (AEIOU) **except** when the vowel is the first
22
+ // letter of the name (Y is treated as a consonant).
23
+ // * Collapse consecutive duplicate letters.
24
+ // * If the result is longer than 6 characters, keep the first 3
25
+ // and the last 3 (e.g. ``ROBERTO`` -> ``RBRT`` -> 4 chars,
26
+ // unchanged; ``CHRISTOPHER`` -> ``CHRSTPHR`` -> ``CHRPHR``).
27
+ //
28
+ // Comparison rules:
29
+ // * Compute the codex of both inputs. If they differ in length
30
+ // by more than 3 letters, return ``false``.
31
+ // * Combined length determines the minimum rating:
32
+ // total <= 4 -> need at least 5
33
+ // 5 <= total <= 7 -> need at least 4
34
+ // 8 <= total <= 11 -> need at least 3
35
+ // total >= 12 -> need at least 2
36
+ // * Strip identical characters position-wise left-to-right.
37
+ // * Strip identical characters position-wise right-to-left
38
+ // (on the reversed remainder).
39
+ // * Rating = 6 - (length of the longer remainder after both
40
+ // stripping passes).
41
+ // * Match iff rating >= minimum.
42
+ //
43
+ // Sources:
44
+ // * Moore, G. (Western Airlines), 1977 — original MRA paper.
45
+ // * Apache Commons Codec MatchRatingApproachEncoder.
46
+ // * https://en.wikipedia.org/wiki/Match_rating_approach
47
+ //
48
+ // Cross-checked against ``jellyfish.match_rating_codex`` and
49
+ // ``jellyfish.match_rating_comparison``.
50
+
51
+ #include <algorithm>
52
+ #include <cstddef>
53
+ #include <string>
54
+ #include <string_view>
55
+
56
+ namespace stride_align::phonetic {
57
+
58
+ namespace mra_detail {
59
+
60
+ inline constexpr bool is_vowel(char c) noexcept {
61
+ return c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U';
62
+ }
63
+
64
+ inline constexpr char to_upper_ascii(char c) noexcept {
65
+ return (c >= 'a' && c <= 'z') ? static_cast<char>(c - ('a' - 'A')) : c;
66
+ }
67
+
68
+ inline constexpr bool is_upper_alpha(char c) noexcept {
69
+ return c >= 'A' && c <= 'Z';
70
+ }
71
+
72
+ } // namespace mra_detail
73
+
74
+ inline std::string match_rating_codex(std::string_view input) {
75
+ using namespace mra_detail;
76
+
77
+ // Pre-pass: keep ASCII letters, upper-case.
78
+ std::string w;
79
+ w.reserve(input.size());
80
+ for (char c : input) {
81
+ const char uc = to_upper_ascii(c);
82
+ if (is_upper_alpha(uc)) w.push_back(uc);
83
+ }
84
+ if (w.empty()) return {};
85
+
86
+ // Step 1: drop vowels except the first letter.
87
+ std::string consonants;
88
+ consonants.reserve(w.size());
89
+ consonants.push_back(w.front());
90
+ for (std::size_t i = 1; i < w.size(); ++i) {
91
+ if (!is_vowel(w[i])) consonants.push_back(w[i]);
92
+ }
93
+
94
+ // Step 2: collapse consecutive duplicates.
95
+ std::string deduped;
96
+ deduped.reserve(consonants.size());
97
+ for (char c : consonants) {
98
+ if (!deduped.empty() && deduped.back() == c) continue;
99
+ deduped.push_back(c);
100
+ }
101
+
102
+ // Step 3: if longer than 6, keep first 3 + last 3.
103
+ if (deduped.size() > 6) {
104
+ deduped = deduped.substr(0, 3) + deduped.substr(deduped.size() - 3, 3);
105
+ }
106
+ return deduped;
107
+ }
108
+
109
+ namespace mra_detail {
110
+
111
+ // Strip identical characters position-wise, left-to-right, from
112
+ // the heads of ``a`` and ``b`` in place. Returns nothing — both
113
+ // are shortened.
114
+ inline void strip_matching_left_to_right(std::string& a, std::string& b) {
115
+ std::string a2, b2;
116
+ a2.reserve(a.size());
117
+ b2.reserve(b.size());
118
+ std::size_t i = 0;
119
+ const std::size_t shared = std::min(a.size(), b.size());
120
+ for (; i < shared; ++i) {
121
+ if (a[i] == b[i]) continue;
122
+ a2.push_back(a[i]);
123
+ b2.push_back(b[i]);
124
+ }
125
+ // Tail of the longer string (if any) is preserved.
126
+ for (; i < a.size(); ++i) a2.push_back(a[i]);
127
+ for (std::size_t j = shared; j < b.size(); ++j) b2.push_back(b[j]);
128
+ a.swap(a2);
129
+ b.swap(b2);
130
+ }
131
+
132
+ } // namespace mra_detail
133
+
134
+ inline bool match_rating_compare(
135
+ std::string_view a, std::string_view b) {
136
+ std::string ca = match_rating_codex(a);
137
+ std::string cb = match_rating_codex(b);
138
+
139
+ // Empty codex on either side: no meaningful comparison.
140
+ if (ca.empty() || cb.empty()) return false;
141
+
142
+ // Length-difference gate.
143
+ const std::size_t la = ca.size();
144
+ const std::size_t lb = cb.size();
145
+ const std::size_t diff = la > lb ? la - lb : lb - la;
146
+ if (diff > 3) return false;
147
+
148
+ // Length-sum -> minimum rating threshold.
149
+ const std::size_t total = la + lb;
150
+ int min_rating;
151
+ if (total <= 4) min_rating = 5;
152
+ else if (total <= 7) min_rating = 4;
153
+ else if (total <= 11) min_rating = 3;
154
+ else min_rating = 2;
155
+
156
+ // Strip identical characters position-wise left-to-right.
157
+ mra_detail::strip_matching_left_to_right(ca, cb);
158
+ // Then right-to-left: reverse, strip, reverse back.
159
+ std::reverse(ca.begin(), ca.end());
160
+ std::reverse(cb.begin(), cb.end());
161
+ mra_detail::strip_matching_left_to_right(ca, cb);
162
+ // (No need to reverse back — only sizes matter from here.)
163
+
164
+ const int rating = 6 - static_cast<int>(std::max(ca.size(), cb.size()));
165
+ return rating >= min_rating;
166
+ }
167
+
168
+ } // namespace stride_align::phonetic
@@ -0,0 +1,291 @@
1
+ #pragma once
2
+
3
+ // Metaphone (Lawrence Philips, 1990).
4
+ //
5
+ // Transforms a word into a phonetic code representing how it sounds.
6
+ // Unlike Soundex (which keeps the leading letter verbatim and emits
7
+ // digits), Metaphone transforms the entire word into letters,
8
+ // keeping the leading vowel only if the word starts with one.
9
+ //
10
+ // Code letters used: ``A B F H J K L M N P R S T W X Y 0`` (zero is
11
+ // the "theta" sound from ``TH``).
12
+ //
13
+ // Reference implementation: this follows the **published Philips
14
+ // 1990 spec** as faithfully reproduced in Apache Commons Codec's
15
+ // Metaphone class. Some popular Python implementations (notably
16
+ // jellyfish) deviate from the spec on a couple of rules — the
17
+ // most visible being CH-after-S (spec: K, jellyfish: X) and GH
18
+ // at end of word (spec: silent, jellyfish: keeps as "KH"). We
19
+ // follow the spec.
20
+ //
21
+ // A standing caveat from Michael Kuhn's 1991 C port (and quoted
22
+ // by the aspell maintainer): when Kuhn transcribed the algorithm
23
+ // from the printed BASIC listing in the 1990 article, he noted
24
+ // "there were discrepancies between the BASIC code and the verbal
25
+ // description. The discrepancies look like they could have been
26
+ // caused by typing errors in the article." So "the published
27
+ // spec" is itself a moving target — the verbal description and
28
+ // the printed code disagree in places. We track the verbal
29
+ // description / Apache Commons Codec branch of the family,
30
+ // which is the most widely cited.
31
+ //
32
+ // Sources:
33
+ // * Philips, L. "Hanging on the Metaphone." Computer Language
34
+ // Magazine 7(12), December 1990, pp. 39-44.
35
+ // * Apache Commons Codec Metaphone:
36
+ // https://commons.apache.org/proper/commons-codec/apidocs/src-html/org/apache/commons/codec/language/Metaphone.html
37
+ // * Lawrence Philips' Metaphone overview (with Kuhn's caveat):
38
+ // http://aspell.net/metaphone/
39
+
40
+ #include <cstddef>
41
+ #include <string>
42
+ #include <string_view>
43
+
44
+ namespace stride_align::phonetic {
45
+
46
+ // Metaphone has two long-running flavours in the wild — the published
47
+ // Philips 1990 spec (as reproduced by Apache Commons Codec) and the
48
+ // jellyfish Python library's interpretation, which deviates on two
49
+ // well-known rules (CH-after-S and GH-at-end-of-word). We expose
50
+ // both as an enum so callers porting from jellyfish can stay
51
+ // bit-exact without giving up the spec for everyone else.
52
+ enum class MetaphoneVariant {
53
+ kPhilips = 0, // published 1990 spec; default
54
+ kJellyfish = 1, // jellyfish-compat (SCH -> SX, GH at end -> KH)
55
+ };
56
+
57
+ namespace metaphone_detail {
58
+
59
+ inline constexpr bool is_vowel(char c) noexcept {
60
+ return c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U';
61
+ }
62
+
63
+ inline constexpr char to_upper_ascii(char c) noexcept {
64
+ return (c >= 'a' && c <= 'z') ? static_cast<char>(c - ('a' - 'A')) : c;
65
+ }
66
+
67
+ inline constexpr bool is_upper_alpha(char c) noexcept {
68
+ return c >= 'A' && c <= 'Z';
69
+ }
70
+
71
+ } // namespace metaphone_detail
72
+
73
+ inline std::string metaphone(std::string_view input,
74
+ MetaphoneVariant variant = MetaphoneVariant::kPhilips) {
75
+ using namespace metaphone_detail;
76
+ const bool jellyfish = variant == MetaphoneVariant::kJellyfish;
77
+
78
+ // Pre-pass: keep ASCII letters only, upper-cased, with
79
+ // adjacent-duplicate collapse (except C — Metaphone deliberately
80
+ // preserves doubled C for the rule that handles "ACCE" etc.).
81
+ // Collapsing before the rule pass lets patterns like "TTH" fall
82
+ // out naturally as "TH" → "0" instead of being split into "T"
83
+ // and an orphan "H".
84
+ std::string w;
85
+ w.reserve(input.size());
86
+ for (char c : input) {
87
+ const char uc = to_upper_ascii(c);
88
+ if (!is_upper_alpha(uc)) continue;
89
+ if (!w.empty() && w.back() == uc && uc != 'C') continue;
90
+ w.push_back(uc);
91
+ }
92
+ if (w.empty()) return {};
93
+
94
+ const std::size_t n = w.size();
95
+ std::string out;
96
+ out.reserve(n);
97
+
98
+ auto get = [&](std::size_t pos) -> char {
99
+ return pos < n ? w[pos] : '\0';
100
+ };
101
+
102
+ // Initial-letter pair simplifications.
103
+ std::size_t i = 0;
104
+ if (n >= 2) {
105
+ const char a = w[0], b = w[1];
106
+ if ((a == 'A' && b == 'E') ||
107
+ (a == 'G' && b == 'N') ||
108
+ (a == 'K' && b == 'N') ||
109
+ (a == 'P' && b == 'N') ||
110
+ (a == 'W' && b == 'R')) {
111
+ i = 1; // drop first letter
112
+ } else if (a == 'W' && b == 'H') {
113
+ out.push_back('W');
114
+ i = 2;
115
+ } else if (a == 'X') {
116
+ out.push_back('S');
117
+ i = 1;
118
+ }
119
+ }
120
+
121
+ for (; i < n; ++i) {
122
+ const char c = w[i];
123
+ const char prev = i > 0 ? w[i - 1] : '\0';
124
+ const char nx = get(i + 1);
125
+ const char nx2 = get(i + 2);
126
+
127
+ switch (c) {
128
+ case 'A': case 'E': case 'I': case 'O': case 'U':
129
+ if (out.empty()) out.push_back(c);
130
+ break;
131
+ case 'B':
132
+ // Drop B at end of word after M (e.g. "lamb").
133
+ if (i + 1 == n && prev == 'M') break;
134
+ out.push_back('B');
135
+ break;
136
+ case 'C':
137
+ if (nx == 'I' && nx2 == 'A') {
138
+ out.push_back('X');
139
+ } else if (nx == 'H') {
140
+ // SCH → SK in Philips; SX in jellyfish. Plain CH → X
141
+ // in both.
142
+ if (prev == 'S' && !jellyfish) {
143
+ out.push_back('K');
144
+ } else {
145
+ out.push_back('X');
146
+ }
147
+ ++i; // consume H
148
+ } else if (nx == 'I' || nx == 'E' || nx == 'Y') {
149
+ // SCI / SCE / SCY: drop the C.
150
+ if (prev == 'S') break;
151
+ out.push_back('S');
152
+ } else {
153
+ out.push_back('K');
154
+ }
155
+ break;
156
+ case 'D':
157
+ if (nx == 'G' && (nx2 == 'E' || nx2 == 'Y' || nx2 == 'I')) {
158
+ out.push_back('J');
159
+ ++i; // consume G
160
+ } else {
161
+ out.push_back('T');
162
+ }
163
+ break;
164
+ case 'F':
165
+ out.push_back('F');
166
+ break;
167
+ case 'G':
168
+ if (nx == 'H') {
169
+ const bool h_at_end = (i + 1 == n - 1);
170
+ const bool h_before_vowel = (i + 2 < n) && is_vowel(nx2);
171
+ if (h_at_end) {
172
+ // jellyfish: GH at end emits K, leaves H to follow.
173
+ // Philips: GH at end is silent.
174
+ if (jellyfish) {
175
+ out.push_back('K');
176
+ // Don't consume H; H rule handles it next iteration.
177
+ } else {
178
+ ++i; // drop both
179
+ }
180
+ break;
181
+ }
182
+ if (!h_before_vowel) {
183
+ // GH not at end, H before a consonant → silent in both.
184
+ ++i;
185
+ break;
186
+ }
187
+ // GH before a vowel: G emits K, leave H for next loop.
188
+ out.push_back('K');
189
+ } else if (nx == 'N') {
190
+ // GN at end of word, or GNED at end → drop the G.
191
+ if (i + 1 == n - 1) {
192
+ // GN at end: drop both
193
+ ++i;
194
+ } else if (i + 3 < n && nx2 == 'E' && w[i + 3] == 'D' &&
195
+ i + 3 == n - 1) {
196
+ // GNED at end of word: drop the G.
197
+ // Fall through to emit nothing for G, the rest is handled normally.
198
+ } else {
199
+ out.push_back('K');
200
+ }
201
+ } else if (nx == 'I' || nx == 'E' || nx == 'Y') {
202
+ if (prev == 'G') break; // GG case
203
+ out.push_back('J');
204
+ } else {
205
+ out.push_back('K');
206
+ }
207
+ break;
208
+ case 'H':
209
+ // Drop H after a vowel and not before a vowel.
210
+ if (prev != '\0' && is_vowel(prev) && !is_vowel(nx)) break;
211
+ out.push_back('H');
212
+ break;
213
+ case 'J':
214
+ out.push_back('J');
215
+ break;
216
+ case 'K':
217
+ // CK collapses to K — drop K when preceded by C.
218
+ if (prev == 'C') break;
219
+ out.push_back('K');
220
+ break;
221
+ case 'L':
222
+ out.push_back('L');
223
+ break;
224
+ case 'M':
225
+ out.push_back('M');
226
+ break;
227
+ case 'N':
228
+ out.push_back('N');
229
+ break;
230
+ case 'P':
231
+ if (nx == 'H') {
232
+ out.push_back('F');
233
+ ++i;
234
+ } else {
235
+ out.push_back('P');
236
+ }
237
+ break;
238
+ case 'Q':
239
+ out.push_back('K');
240
+ break;
241
+ case 'R':
242
+ out.push_back('R');
243
+ break;
244
+ case 'S':
245
+ if (nx == 'H') {
246
+ out.push_back('X');
247
+ ++i;
248
+ } else if (nx == 'I' && (nx2 == 'A' || nx2 == 'O')) {
249
+ out.push_back('X');
250
+ } else {
251
+ out.push_back('S');
252
+ }
253
+ break;
254
+ case 'T':
255
+ if (nx == 'H') {
256
+ out.push_back('0'); // theta
257
+ ++i;
258
+ } else if (nx == 'I' && (nx2 == 'A' || nx2 == 'O')) {
259
+ out.push_back('X');
260
+ } else if (nx == 'C' && nx2 == 'H') {
261
+ // T is silent before CH (e.g. "match").
262
+ break;
263
+ } else {
264
+ out.push_back('T');
265
+ }
266
+ break;
267
+ case 'V':
268
+ out.push_back('F');
269
+ break;
270
+ case 'W':
271
+ // W emits only when followed by a vowel (e.g. "Williamson").
272
+ if (is_vowel(nx)) out.push_back('W');
273
+ break;
274
+ case 'X':
275
+ out.push_back('K');
276
+ out.push_back('S');
277
+ break;
278
+ case 'Y':
279
+ // Y emits only when followed by a vowel.
280
+ if (is_vowel(nx)) out.push_back('Y');
281
+ break;
282
+ case 'Z':
283
+ out.push_back('S');
284
+ break;
285
+ }
286
+ }
287
+
288
+ return out;
289
+ }
290
+
291
+ } // namespace stride_align::phonetic