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,796 @@
1
+ #pragma once
2
+
3
+ // Double Metaphone (Lawrence Philips, 2000).
4
+ //
5
+ // Produces two phonetic codes — a *primary* and an *alternate* —
6
+ // per input name. The alternate captures plausible non-English
7
+ // pronunciations (Italian, Spanish, German, Slavic, Greek
8
+ // patterns) and is empty (matches primary) for names that have
9
+ // only one reasonable encoding.
10
+ //
11
+ // Returns ``DoubleMetaphoneResult{primary, alternate}`` where
12
+ // ``alternate`` is an empty string when no second pronunciation
13
+ // applies. Maximum output length defaults to 4 (the original
14
+ // Philips spec), set via a kwarg on the public API.
15
+ //
16
+ // Reference: Lawrence Philips, "The Double Metaphone Search
17
+ // Algorithm", C/C++ Users Journal, June 2000. We mirror the
18
+ // algorithmic structure of Apache Commons Codec's DoubleMetaphone
19
+ // class (which is the de-facto open-source reference) — Slavo-
20
+ // Germanic and silent-start detection, the heavy letter handlers
21
+ // (C, G, S, T), and the per-character cursor-advance pattern.
22
+ //
23
+ // Cross-checked against the ``metaphone`` and ``doublemetaphone``
24
+ // Python packages. They agree on every name we tested except one
25
+ // pattern: GH preceded by a vowel at position 1 or 2 from the start
26
+ // (e.g. "Hugh", "High"). Apache Commons Codec (and the
27
+ // ``doublemetaphone`` PyPI port that mirrors it) say GH is silent
28
+ // here, giving "Hugh" -> "H". The ``metaphone`` PyPI package leaks
29
+ // the previous character's emission through a missing else clause,
30
+ // giving "Hugh" -> "HH". We treat that as a documented variation
31
+ // rather than picking a winner — ``DoubleMetaphoneVariant`` selects.
32
+
33
+ #include <cstddef>
34
+ #include <cstdint>
35
+ #include <initializer_list>
36
+ #include <string>
37
+ #include <string_view>
38
+
39
+ namespace stride_align::phonetic {
40
+
41
+ // Selects which published behaviour to mimic. The two values are
42
+ // NOT equal-weight algorithmic variations — ``kCommons`` is the
43
+ // faithful Lawrence Philips port (Apache Commons Codec, and the
44
+ // ``doublemetaphone`` PyPI package that mirrors it). ``kPython``
45
+ // reproduces a known bug in the ``metaphone`` PyPI package: a
46
+ // missing ``else`` in its ``process_g`` GH branch lets the previous
47
+ // character's ``self.next`` tuple leak into the next iteration, so
48
+ // names like "Hugh" and "High" come out "HH" instead of "H". We
49
+ // expose it as an opt-in for callers cross-checking against that
50
+ // specific library; if you don't have that constraint, stay on
51
+ // ``kCommons``.
52
+ enum class DoubleMetaphoneVariant : int {
53
+ // Apache Commons Codec semantics. "Hugh" -> ("H", "").
54
+ // Recommended default; correct per the published spec.
55
+ kCommons = 0,
56
+ // ``metaphone`` PyPI package bug-compat. "Hugh" -> ("HH", "").
57
+ // Opt in only when you need byte-for-byte agreement with that
58
+ // library; the divergent code paths are documented bugs, not
59
+ // legitimate algorithmic alternatives.
60
+ kPython = 1,
61
+ };
62
+
63
+ struct DoubleMetaphoneResult {
64
+ std::string primary;
65
+ std::string alternate;
66
+ };
67
+
68
+ namespace double_metaphone_detail {
69
+
70
+ inline constexpr char to_upper_ascii(char c) noexcept {
71
+ return (c >= 'a' && c <= 'z') ? static_cast<char>(c - ('a' - 'A')) : c;
72
+ }
73
+
74
+ inline constexpr bool is_upper_alpha(char c) noexcept {
75
+ return c >= 'A' && c <= 'Z';
76
+ }
77
+
78
+ inline bool is_vowel(const std::string& w, std::size_t pos) noexcept {
79
+ if (pos >= w.size()) return false;
80
+ const char c = w[pos];
81
+ return c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U' || c == 'Y';
82
+ }
83
+
84
+ // Test whether ``w`` from position ``pos`` for ``len`` characters
85
+ // matches any of the strings in ``cands``. Returns false if the
86
+ // span runs off either end of ``w``. ``pos`` is signed so call
87
+ // sites passing ``pos - N`` (where ``pos`` is ``size_t``) don't
88
+ // trigger out-of-range exceptions on underflow — a wrapped-around
89
+ // size_t casts to a negative ptrdiff_t and gets rejected here.
90
+ inline bool contains_at(const std::string& w,
91
+ std::ptrdiff_t pos,
92
+ std::size_t len,
93
+ std::initializer_list<std::string_view> cands) noexcept {
94
+ if (pos < 0) return false;
95
+ const std::size_t upos = static_cast<std::size_t>(pos);
96
+ if (upos + len > w.size()) return false;
97
+ for (const auto& c : cands) {
98
+ if (c.size() != len) continue;
99
+ if (w.compare(upos, len, c.data(), c.size()) == 0) return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ // Slavo-Germanic flag: presence of W, K, CZ, or WITZ anywhere.
105
+ inline bool is_slavo_germanic(const std::string& w) noexcept {
106
+ return w.find('W') != std::string::npos
107
+ || w.find('K') != std::string::npos
108
+ || w.find("CZ") != std::string::npos
109
+ || w.find("WITZ") != std::string::npos;
110
+ }
111
+
112
+ inline bool is_silent_start(const std::string& w) noexcept {
113
+ return contains_at(w, 0, 2, {"GN", "KN", "PN", "WR", "PS"});
114
+ }
115
+
116
+ // Append to both primary and alternate (the common case).
117
+ inline void append(DoubleMetaphoneResult& r, char c) {
118
+ r.primary.push_back(c);
119
+ r.alternate.push_back(c);
120
+ }
121
+
122
+ inline void append(DoubleMetaphoneResult& r, std::string_view s) {
123
+ r.primary.append(s);
124
+ r.alternate.append(s);
125
+ }
126
+
127
+ // Append different characters to primary and alternate.
128
+ inline void append(DoubleMetaphoneResult& r, char p, char a) {
129
+ r.primary.push_back(p);
130
+ r.alternate.push_back(a);
131
+ }
132
+
133
+ inline void append(DoubleMetaphoneResult& r,
134
+ std::string_view p, std::string_view a) {
135
+ r.primary.append(p);
136
+ r.alternate.append(a);
137
+ }
138
+
139
+ inline bool full(const DoubleMetaphoneResult& r, std::size_t max_len) {
140
+ return r.primary.size() >= max_len && r.alternate.size() >= max_len;
141
+ }
142
+
143
+ // ---- per-letter handlers --------------------------------------------------
144
+
145
+ inline std::size_t handle_AEIOUY(
146
+ DoubleMetaphoneResult& r, std::size_t pos) {
147
+ if (pos == 0) append(r, 'A');
148
+ return pos + 1;
149
+ }
150
+
151
+ inline std::size_t handle_B(
152
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
153
+ append(r, 'P');
154
+ return (pos + 1 < w.size() && w[pos + 1] == 'B') ? pos + 2 : pos + 1;
155
+ }
156
+
157
+ inline std::size_t handle_C(
158
+ DoubleMetaphoneResult& r,
159
+ const std::string& w,
160
+ std::size_t pos,
161
+ bool slavo_germanic) {
162
+ const std::size_t n = w.size();
163
+ auto at = [&](std::size_t p) -> char {
164
+ return p < n ? w[p] : '\0';
165
+ };
166
+
167
+ // Various Germanic, Greek, otherwise contexts.
168
+ if (pos > 1
169
+ && !is_vowel(w, pos - 2)
170
+ && contains_at(w, pos - 1, 3, {"ACH"})
171
+ && at(pos + 2) != 'I'
172
+ && (at(pos + 2) != 'E'
173
+ || contains_at(w, pos - 2, 6, {"BACHER", "MACHER"}))) {
174
+ append(r, 'K');
175
+ return pos + 2;
176
+ }
177
+ // Special case for Caesar.
178
+ if (pos == 0 && contains_at(w, 0, 6, {"CAESAR"})) {
179
+ append(r, 'S');
180
+ return pos + 2;
181
+ }
182
+ if (contains_at(w, pos, 4, {"CHIA"})) {
183
+ append(r, 'K');
184
+ return pos + 2;
185
+ }
186
+ if (contains_at(w, pos, 2, {"CH"})) {
187
+ if (pos > 0 && contains_at(w, pos, 4, {"CHAE"})) {
188
+ append(r, 'K', 'X');
189
+ return pos + 2;
190
+ }
191
+ // Greek roots, e.g. Chemistry, Chorus.
192
+ if (pos == 0
193
+ && (contains_at(w, 1, 5, {"HARAC", "HARIS"})
194
+ || contains_at(w, 1, 3, {"HOR", "HYM", "HIA", "HEM"}))
195
+ && !contains_at(w, 0, 5, {"CHORE"})) {
196
+ append(r, 'K');
197
+ return pos + 2;
198
+ }
199
+ // Germanic, Greek, or otherwise.
200
+ if (contains_at(w, 0, 4, {"VAN ", "VON "})
201
+ || contains_at(w, 0, 3, {"SCH"})
202
+ || contains_at(w, pos - 2, 6, {"ORCHES", "ARCHIT", "ORCHID"})
203
+ || contains_at(w, pos + 2, 1, {"T", "S"})
204
+ || ((pos == 0 || contains_at(w, pos - 1, 1, {"A", "O", "U", "E"}))
205
+ && contains_at(w, pos + 2, 1, {
206
+ "L", "R", "N", "M", "B", "H", "F", "V", "W", " "}))) {
207
+ append(r, 'K');
208
+ } else {
209
+ if (pos > 0) {
210
+ if (contains_at(w, 0, 2, {"MC"})) {
211
+ append(r, 'K');
212
+ } else {
213
+ append(r, 'X', 'K');
214
+ }
215
+ } else {
216
+ append(r, 'X');
217
+ }
218
+ }
219
+ return pos + 2;
220
+ }
221
+ // Italian CZ.
222
+ if (contains_at(w, pos, 2, {"CZ"})
223
+ && !contains_at(w, pos - 2, 4, {"WICZ"})) {
224
+ append(r, 'S', 'X');
225
+ return pos + 2;
226
+ }
227
+ // Italian CIO/CIE/CIA.
228
+ if (contains_at(w, pos + 1, 3, {"CIA"})) {
229
+ append(r, 'X');
230
+ return pos + 3;
231
+ }
232
+ // Double CC.
233
+ if (contains_at(w, pos, 2, {"CC"})
234
+ && !(pos == 1 && at(0) == 'M')) {
235
+ // ccI, ccE, ccH but not CCE/CCI in coup etc.
236
+ if (contains_at(w, pos + 2, 1, {"I", "E", "H"})
237
+ && !contains_at(w, pos + 2, 2, {"HU"})) {
238
+ // Accident, Accede, Succeed
239
+ if ((pos == 1 && at(pos - 1) == 'A')
240
+ || contains_at(w, pos - 1, 5, {"UCCEE", "UCCES"})) {
241
+ append(r, "KS");
242
+ } else {
243
+ append(r, 'X');
244
+ }
245
+ return pos + 3;
246
+ }
247
+ // Bacci, Bertucci, etc.
248
+ append(r, 'K');
249
+ return pos + 2;
250
+ }
251
+ if (contains_at(w, pos, 2, {"CK", "CG", "CQ"})) {
252
+ append(r, 'K');
253
+ return pos + 2;
254
+ }
255
+ if (contains_at(w, pos, 2, {"CI", "CE", "CY"})) {
256
+ // Italian vs. English.
257
+ if (contains_at(w, pos, 3, {"CIO", "CIE", "CIA"})) {
258
+ append(r, 'S', 'X');
259
+ } else {
260
+ append(r, 'S');
261
+ }
262
+ return pos + 2;
263
+ }
264
+ append(r, 'K');
265
+ // Mac+something - skip the second character.
266
+ if (contains_at(w, pos + 1, 2, {" C", " Q", " G"})) {
267
+ return pos + 3;
268
+ }
269
+ if (contains_at(w, pos + 1, 1, {"C", "K", "Q"})
270
+ && !contains_at(w, pos + 1, 2, {"CE", "CI"})) {
271
+ return pos + 2;
272
+ }
273
+ return pos + 1;
274
+ }
275
+
276
+ inline std::size_t handle_D(
277
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
278
+ if (contains_at(w, pos, 2, {"DG"})) {
279
+ if (contains_at(w, pos + 2, 1, {"I", "E", "Y"})) {
280
+ append(r, 'J');
281
+ return pos + 3;
282
+ }
283
+ append(r, "TK");
284
+ return pos + 2;
285
+ }
286
+ if (contains_at(w, pos, 2, {"DT", "DD"})) {
287
+ append(r, 'T');
288
+ return pos + 2;
289
+ }
290
+ append(r, 'T');
291
+ return pos + 1;
292
+ }
293
+
294
+ inline std::size_t handle_G(
295
+ DoubleMetaphoneResult& r,
296
+ const std::string& w,
297
+ std::size_t pos,
298
+ bool slavo_germanic,
299
+ DoubleMetaphoneVariant variant) {
300
+ const std::size_t n = w.size();
301
+ auto at = [&](std::size_t p) -> char {
302
+ return p < n ? w[p] : '\0';
303
+ };
304
+ if (at(pos + 1) == 'H') {
305
+ if (pos > 0 && !is_vowel(w, pos - 1)) {
306
+ append(r, 'K');
307
+ return pos + 2;
308
+ }
309
+ if (pos == 0) {
310
+ if (at(pos + 2) == 'I') {
311
+ append(r, 'J');
312
+ } else {
313
+ append(r, 'K');
314
+ }
315
+ return pos + 2;
316
+ }
317
+ // metaphone-py compat: at this point ``pos > 0`` and the char
318
+ // before G is a vowel. The PyPI ``metaphone`` package has an
319
+ // elif chain whose ``position < start_index + 3`` clause
320
+ // shortcircuits Parker's rule and, when ``pos`` is 1 or 2,
321
+ // leaves ``self.next`` at the previous letter's tuple — so the
322
+ // previous emit fires again. We replicate the symptom directly
323
+ // here: re-append whatever was last emitted. Apache Commons
324
+ // falls through to Parker (the next block) instead.
325
+ if (variant == DoubleMetaphoneVariant::kPython && pos < 3) {
326
+ if (!r.primary.empty()) r.primary.push_back(r.primary.back());
327
+ if (!r.alternate.empty())r.alternate.push_back(r.alternate.back());
328
+ return pos + 2;
329
+ }
330
+ // Parker's rules — silent in many contexts.
331
+ if ((pos > 1 && contains_at(w, pos - 2, 1, {"B", "H", "D"}))
332
+ || (pos > 2 && contains_at(w, pos - 3, 1, {"B", "H", "D"}))
333
+ || (pos > 3 && contains_at(w, pos - 4, 1, {"B", "H"}))) {
334
+ return pos + 2;
335
+ }
336
+ // E.g. "laugh", "cough"
337
+ if (pos > 2 && at(pos - 1) == 'U'
338
+ && contains_at(w, pos - 3, 1, {"C", "G", "L", "R", "T"})) {
339
+ append(r, 'F');
340
+ return pos + 2;
341
+ }
342
+ if (pos > 0 && at(pos - 1) != 'I') {
343
+ append(r, 'K');
344
+ }
345
+ return pos + 2;
346
+ }
347
+ if (at(pos + 1) == 'N') {
348
+ if (pos == 1 && is_vowel(w, 0) && !slavo_germanic) {
349
+ append(r, "KN", "N");
350
+ return pos + 2;
351
+ }
352
+ if (!contains_at(w, pos + 2, 2, {"EY"})
353
+ && at(pos + 1) != 'Y' && !slavo_germanic) {
354
+ append(r, "N", "KN");
355
+ return pos + 2;
356
+ }
357
+ append(r, "KN");
358
+ return pos + 2;
359
+ }
360
+ if (contains_at(w, pos + 1, 2, {"LI"}) && !slavo_germanic) {
361
+ append(r, "KL", "L");
362
+ return pos + 2;
363
+ }
364
+ if (pos == 0
365
+ && (at(pos + 1) == 'Y'
366
+ || contains_at(w, pos + 1, 2, {
367
+ "ES", "EP", "EB", "EL", "EY", "IB", "IL", "IN", "IE", "EI", "ER"}))) {
368
+ append(r, 'K', 'J');
369
+ return pos + 2;
370
+ }
371
+ if ((contains_at(w, pos + 1, 2, {"ER"})
372
+ || at(pos + 1) == 'Y')
373
+ && !contains_at(w, 0, 6, {"DANGER", "RANGER", "MANGER"})
374
+ && !contains_at(w, pos - 1, 1, {"E", "I"})
375
+ && !contains_at(w, pos - 1, 3, {"RGY", "OGY"})) {
376
+ append(r, 'K', 'J');
377
+ return pos + 2;
378
+ }
379
+ if (contains_at(w, pos + 1, 1, {"E", "I", "Y"})
380
+ || contains_at(w, pos - 1, 4, {"AGGI", "OGGI"})) {
381
+ if (contains_at(w, 0, 4, {"VAN ", "VON "})
382
+ || contains_at(w, 0, 3, {"SCH"})
383
+ || contains_at(w, pos + 1, 2, {"ET"})) {
384
+ append(r, 'K');
385
+ } else if (contains_at(w, pos + 1, 3, {"IER"})) {
386
+ append(r, 'J');
387
+ } else {
388
+ append(r, 'J', 'K');
389
+ }
390
+ return pos + 2;
391
+ }
392
+ if (at(pos + 1) == 'G') {
393
+ append(r, 'K');
394
+ return pos + 2;
395
+ }
396
+ append(r, 'K');
397
+ return pos + 1;
398
+ }
399
+
400
+ inline std::size_t handle_H(
401
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
402
+ if ((pos == 0 || is_vowel(w, pos - 1)) && is_vowel(w, pos + 1)) {
403
+ append(r, 'H');
404
+ return pos + 2;
405
+ }
406
+ return pos + 1;
407
+ }
408
+
409
+ inline std::size_t handle_J(
410
+ DoubleMetaphoneResult& r,
411
+ const std::string& w,
412
+ std::size_t pos,
413
+ bool slavo_germanic) {
414
+ const std::size_t n = w.size();
415
+ if (contains_at(w, pos, 4, {"JOSE"}) || contains_at(w, 0, 4, {"SAN "})) {
416
+ if ((pos == 0 && pos + 4 < n && w[pos + 4] == ' ')
417
+ || contains_at(w, 0, 4, {"SAN "})) {
418
+ append(r, 'H');
419
+ } else {
420
+ append(r, 'J', 'H');
421
+ }
422
+ return pos + 1;
423
+ }
424
+ if (pos == 0 && !contains_at(w, pos, 4, {"JOSE"})) {
425
+ append(r, 'J', 'A');
426
+ } else if (is_vowel(w, pos - 1) && !slavo_germanic
427
+ && (pos + 1 < n && (w[pos + 1] == 'A' || w[pos + 1] == 'O'))) {
428
+ append(r, 'J', 'H');
429
+ } else if (pos + 1 == n) {
430
+ append(r, 'J', ' ');
431
+ } else if (pos + 1 < n
432
+ && !contains_at(w, pos + 1, 1, {"L", "T", "K", "S", "N", "M", "B", "Z"})
433
+ && !(pos > 0
434
+ && contains_at(w, pos - 1, 1, {"S", "K", "L"}))) {
435
+ append(r, 'J');
436
+ }
437
+ return (pos + 1 < n && w[pos + 1] == 'J') ? pos + 2 : pos + 1;
438
+ }
439
+
440
+ inline std::size_t handle_L(
441
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
442
+ const std::size_t n = w.size();
443
+ if (pos + 1 < n && w[pos + 1] == 'L') {
444
+ // Spanish/Italian "ll": appended differently between primary
445
+ // and alternate when at certain word-final positions.
446
+ if ((pos == n - 3
447
+ && contains_at(w, pos - 1, 4, {"ILLO", "ILLA", "ALLE"}))
448
+ || ((contains_at(w, n - 2, 2, {"AS", "OS"}) || (n >= 1 && (w[n - 1] == 'A' || w[n - 1] == 'O')))
449
+ && contains_at(w, pos - 1, 4, {"ALLE"}))) {
450
+ r.primary.push_back('L');
451
+ // alternate gets nothing (silent in the Spanish reading).
452
+ return pos + 2;
453
+ }
454
+ append(r, 'L');
455
+ return pos + 2;
456
+ }
457
+ append(r, 'L');
458
+ return pos + 1;
459
+ }
460
+
461
+ inline std::size_t handle_M(
462
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
463
+ const std::size_t n = w.size();
464
+ append(r, 'M');
465
+ if ((pos + 1 < n && w[pos + 1] == 'M')
466
+ || (pos > 0 && contains_at(w, pos - 1, 3, {"UMB"})
467
+ && (pos + 1 == n
468
+ || contains_at(w, pos + 2, 2, {"ER"})))) {
469
+ return pos + 2;
470
+ }
471
+ return pos + 1;
472
+ }
473
+
474
+ inline std::size_t handle_N(
475
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
476
+ append(r, 'N');
477
+ return (pos + 1 < w.size() && w[pos + 1] == 'N') ? pos + 2 : pos + 1;
478
+ }
479
+
480
+ inline std::size_t handle_P(
481
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
482
+ if (pos + 1 < w.size() && w[pos + 1] == 'H') {
483
+ append(r, 'F');
484
+ return pos + 2;
485
+ }
486
+ append(r, 'P');
487
+ return (pos + 1 < w.size() && (w[pos + 1] == 'P' || w[pos + 1] == 'B')) ? pos + 2 : pos + 1;
488
+ }
489
+
490
+ inline std::size_t handle_Q(
491
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
492
+ append(r, 'K');
493
+ return (pos + 1 < w.size() && w[pos + 1] == 'Q') ? pos + 2 : pos + 1;
494
+ }
495
+
496
+ inline std::size_t handle_R(
497
+ DoubleMetaphoneResult& r,
498
+ const std::string& w,
499
+ std::size_t pos,
500
+ bool slavo_germanic) {
501
+ const std::size_t n = w.size();
502
+ // French -IER: primary appends nothing, alternate appends R.
503
+ if (pos + 1 == n && !slavo_germanic
504
+ && contains_at(w, pos - 2, 2, {"IE"})
505
+ && !contains_at(w, pos - 4, 2, {"ME", "MA"})) {
506
+ r.alternate.push_back('R');
507
+ return pos + 1;
508
+ }
509
+ append(r, 'R');
510
+ return (pos + 1 < n && w[pos + 1] == 'R') ? pos + 2 : pos + 1;
511
+ }
512
+
513
+ inline std::size_t handle_S(
514
+ DoubleMetaphoneResult& r,
515
+ const std::string& w,
516
+ std::size_t pos,
517
+ bool slavo_germanic) {
518
+ const std::size_t n = w.size();
519
+ if (pos > 0 && contains_at(w, pos - 1, 3, {"ISL", "YSL"})) {
520
+ return pos + 1;
521
+ }
522
+ if (pos == 0 && contains_at(w, 0, 5, {"SUGAR"})) {
523
+ append(r, 'X', 'S');
524
+ return pos + 1;
525
+ }
526
+ if (contains_at(w, pos, 2, {"SH"})) {
527
+ if (contains_at(w, pos + 1, 4, {"HEIM", "HOEK", "HOLM", "HOLZ"})) {
528
+ append(r, 'S');
529
+ } else {
530
+ append(r, 'X');
531
+ }
532
+ return pos + 2;
533
+ }
534
+ if (contains_at(w, pos, 3, {"SIO", "SIA"})
535
+ || contains_at(w, pos, 4, {"SIAN"})) {
536
+ if (!slavo_germanic) append(r, 'S', 'X');
537
+ else append(r, 'S');
538
+ return pos + 3;
539
+ }
540
+ if ((pos == 0
541
+ && contains_at(w, pos + 1, 1, {"M", "N", "L", "W"}))
542
+ || contains_at(w, pos + 1, 1, {"Z"})) {
543
+ append(r, 'S', 'X');
544
+ if (pos + 1 < n && w[pos + 1] == 'Z') return pos + 2;
545
+ return pos + 1;
546
+ }
547
+ if (contains_at(w, pos, 2, {"SC"})) {
548
+ if (pos + 2 < n && w[pos + 2] == 'H') {
549
+ // Dutch origin: schermerhorn, schenker.
550
+ if (contains_at(w, pos + 3, 2, {
551
+ "OO", "ER", "EN", "UY", "ED", "EM"})) {
552
+ if (contains_at(w, pos + 3, 2, {"ER", "EN"})) {
553
+ append(r, "X", "SK");
554
+ } else {
555
+ append(r, "SK");
556
+ }
557
+ } else {
558
+ if (pos == 0 && !is_vowel(w, 3) && w.size() > 3 && w[3] != 'W') {
559
+ append(r, 'X', 'S');
560
+ } else {
561
+ append(r, 'X');
562
+ }
563
+ }
564
+ return pos + 3;
565
+ }
566
+ if (contains_at(w, pos + 2, 1, {"I", "E", "Y"})) {
567
+ append(r, 'S');
568
+ return pos + 3;
569
+ }
570
+ append(r, "SK");
571
+ return pos + 3;
572
+ }
573
+ // French: silent S at end of word like "Debois"/"des".
574
+ if (pos + 1 == n && contains_at(w, pos - 2, 2, {"AI", "OI"})) {
575
+ r.alternate.push_back('S');
576
+ return pos + 1;
577
+ }
578
+ append(r, 'S');
579
+ return (pos + 1 < n && (w[pos + 1] == 'S' || w[pos + 1] == 'Z')) ? pos + 2 : pos + 1;
580
+ }
581
+
582
+ inline std::size_t handle_T(
583
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
584
+ const std::size_t n = w.size();
585
+ if (contains_at(w, pos, 4, {"TION"})
586
+ || contains_at(w, pos, 3, {"TIA", "TCH"})) {
587
+ append(r, 'X');
588
+ return pos + 3;
589
+ }
590
+ if (contains_at(w, pos, 2, {"TH"})
591
+ || contains_at(w, pos, 3, {"TTH"})) {
592
+ // Special case: Thomas, Thames.
593
+ if (contains_at(w, pos + 2, 2, {"OM", "AM"})
594
+ || contains_at(w, 0, 4, {"VAN ", "VON "})
595
+ || contains_at(w, 0, 3, {"SCH"})) {
596
+ append(r, 'T');
597
+ } else {
598
+ append(r, '0', 'T');
599
+ }
600
+ return pos + 2;
601
+ }
602
+ append(r, 'T');
603
+ return (pos + 1 < n && (w[pos + 1] == 'T' || w[pos + 1] == 'D')) ? pos + 2 : pos + 1;
604
+ }
605
+
606
+ inline std::size_t handle_V(
607
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
608
+ append(r, 'F');
609
+ return (pos + 1 < w.size() && w[pos + 1] == 'V') ? pos + 2 : pos + 1;
610
+ }
611
+
612
+ inline std::size_t handle_W(
613
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
614
+ const std::size_t n = w.size();
615
+ // WR — like R.
616
+ if (contains_at(w, pos, 2, {"WR"})) {
617
+ append(r, 'R');
618
+ return pos + 2;
619
+ }
620
+ if (pos == 0
621
+ && (is_vowel(w, pos + 1) || contains_at(w, pos, 2, {"WH"}))) {
622
+ if (is_vowel(w, pos + 1)) {
623
+ append(r, 'A', 'F');
624
+ } else {
625
+ append(r, 'A');
626
+ }
627
+ return pos + 1;
628
+ }
629
+ if ((pos + 1 == n && is_vowel(w, pos - 1))
630
+ || contains_at(w, pos - 1, 5, {"EWSKI", "EWSKY", "OWSKI", "OWSKY"})
631
+ || contains_at(w, 0, 3, {"SCH"})) {
632
+ r.alternate.push_back('F');
633
+ return pos + 1;
634
+ }
635
+ if (contains_at(w, pos, 4, {"WICZ", "WITZ"})) {
636
+ append(r, "TS", "FX");
637
+ return pos + 4;
638
+ }
639
+ return pos + 1;
640
+ }
641
+
642
+ inline std::size_t handle_X(
643
+ DoubleMetaphoneResult& r, const std::string& w, std::size_t pos) {
644
+ if (pos == 0) {
645
+ append(r, 'S');
646
+ return pos + 1;
647
+ }
648
+ const std::size_t n = w.size();
649
+ if (!(pos + 1 == n
650
+ && (contains_at(w, pos - 3, 3, {"IAU", "EAU"})
651
+ || contains_at(w, pos - 2, 2, {"AU", "OU"})))) {
652
+ append(r, "KS");
653
+ }
654
+ return (pos + 1 < n && (w[pos + 1] == 'C' || w[pos + 1] == 'X')) ? pos + 2 : pos + 1;
655
+ }
656
+
657
+ inline std::size_t handle_Z(
658
+ DoubleMetaphoneResult& r,
659
+ const std::string& w,
660
+ std::size_t pos,
661
+ bool slavo_germanic) {
662
+ if (pos + 1 < w.size() && w[pos + 1] == 'H') {
663
+ append(r, 'J');
664
+ return pos + 2;
665
+ }
666
+ // Apache Commons Codec emits the split "S"/"TS" only when the
667
+ // preceding letter is NOT T — names like "Schwartz" (TZ ending) get
668
+ // a single S so the alternate isn't doubled. We had the predicate
669
+ // inverted in an earlier draft; see issue tested by Schwartz.
670
+ if (contains_at(w, pos + 1, 2, {"ZO", "ZI", "ZA"})
671
+ || (slavo_germanic && pos > 0
672
+ && !contains_at(w, pos - 1, 1, {"T"}))) {
673
+ append(r, std::string_view("S"), std::string_view("TS"));
674
+ } else {
675
+ append(r, 'S');
676
+ }
677
+ return (pos + 1 < w.size() && w[pos + 1] == 'Z') ? pos + 2 : pos + 1;
678
+ }
679
+
680
+ } // namespace double_metaphone_detail
681
+
682
+ // max_length defaults to a generous bound rather than the
683
+ // historical 4 — modern reference implementations (the ``metaphone``
684
+ // Python package, Apache Commons Codec when configured) emit the
685
+ // full code and let the caller slice. Pass a smaller value if the
686
+ // classical 4-char Philips form is needed.
687
+ inline DoubleMetaphoneResult double_metaphone(
688
+ std::string_view input,
689
+ std::size_t max_length = 64,
690
+ DoubleMetaphoneVariant variant = DoubleMetaphoneVariant::kCommons) {
691
+ using namespace double_metaphone_detail;
692
+
693
+ DoubleMetaphoneResult r;
694
+ std::string w;
695
+ w.reserve(input.size());
696
+ for (char c : input) {
697
+ const char uc = to_upper_ascii(c);
698
+ if (is_upper_alpha(uc)) w.push_back(uc);
699
+ }
700
+ if (w.empty()) return r;
701
+
702
+ const bool slavo = is_slavo_germanic(w);
703
+ std::size_t pos = is_silent_start(w) ? 1 : 0;
704
+
705
+ if (w.front() == 'X') {
706
+ append(r, 'S');
707
+ pos = 1;
708
+ }
709
+
710
+ while (pos < w.size() && !full(r, max_length)) {
711
+ const char c = w[pos];
712
+ switch (c) {
713
+ case 'A': case 'E': case 'I': case 'O': case 'U': case 'Y':
714
+ pos = handle_AEIOUY(r, pos);
715
+ break;
716
+ case 'B':
717
+ pos = handle_B(r, w, pos);
718
+ break;
719
+ case 'C':
720
+ pos = handle_C(r, w, pos, slavo);
721
+ break;
722
+ case 'D':
723
+ pos = handle_D(r, w, pos);
724
+ break;
725
+ case 'F':
726
+ append(r, 'F');
727
+ pos += (pos + 1 < w.size() && w[pos + 1] == 'F') ? 2 : 1;
728
+ break;
729
+ case 'G':
730
+ pos = handle_G(r, w, pos, slavo, variant);
731
+ break;
732
+ case 'H':
733
+ pos = handle_H(r, w, pos);
734
+ break;
735
+ case 'J':
736
+ pos = handle_J(r, w, pos, slavo);
737
+ break;
738
+ case 'K':
739
+ append(r, 'K');
740
+ pos += (pos + 1 < w.size() && w[pos + 1] == 'K') ? 2 : 1;
741
+ break;
742
+ case 'L':
743
+ pos = handle_L(r, w, pos);
744
+ break;
745
+ case 'M':
746
+ pos = handle_M(r, w, pos);
747
+ break;
748
+ case 'N':
749
+ pos = handle_N(r, w, pos);
750
+ break;
751
+ case 'P':
752
+ pos = handle_P(r, w, pos);
753
+ break;
754
+ case 'Q':
755
+ pos = handle_Q(r, w, pos);
756
+ break;
757
+ case 'R':
758
+ pos = handle_R(r, w, pos, slavo);
759
+ break;
760
+ case 'S':
761
+ pos = handle_S(r, w, pos, slavo);
762
+ break;
763
+ case 'T':
764
+ pos = handle_T(r, w, pos);
765
+ break;
766
+ case 'V':
767
+ pos = handle_V(r, w, pos);
768
+ break;
769
+ case 'W':
770
+ pos = handle_W(r, w, pos);
771
+ break;
772
+ case 'X':
773
+ pos = handle_X(r, w, pos);
774
+ break;
775
+ case 'Z':
776
+ pos = handle_Z(r, w, pos, slavo);
777
+ break;
778
+ default:
779
+ ++pos;
780
+ break;
781
+ }
782
+ }
783
+
784
+ if (r.primary.size() > max_length) r.primary.resize(max_length);
785
+ if (r.alternate.size() > max_length) r.alternate.resize(max_length);
786
+ // The Apache Commons reference always emits both codes even when
787
+ // identical. The Python ``metaphone`` package — our cross-check
788
+ // oracle — emits an empty alternate when no two-arg ``append``
789
+ // ever diverged it from primary. Match that here so callers can
790
+ // cheaply test ``if alt:`` for "did the name have a second
791
+ // pronunciation".
792
+ if (r.primary == r.alternate) r.alternate.clear();
793
+ return r;
794
+ }
795
+
796
+ } // namespace stride_align::phonetic