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,176 @@
1
+ #pragma once
2
+
3
+ // N-gram set similarity — Phase D.1.
4
+ //
5
+ // Four metrics over the multiset of character n-grams of each input:
6
+ //
7
+ // * **Jaccard.** J(A, B) = |A ∩ B| / |A ∪ B|
8
+ // * **Sørensen-Dice.** D(A, B) = 2·|A ∩ B| / (|A| + |B|)
9
+ // * **Cosine.** cos(A, B) = ⟨A, B⟩ / (‖A‖·‖B‖) over the
10
+ // multiset frequency vectors.
11
+ // * **Overlap.** O(A, B) = |A ∩ B| / min(|A|, |B|)
12
+ //
13
+ // All four are computed over MULTISETS (each n-gram counted with
14
+ // multiplicity). For ``a = "abab"`` and ``n = 2`` the multiset is
15
+ // ``{"ab": 2, "ba": 1}``; for ``a = "aaaa"`` and ``n = 2`` it is
16
+ // ``{"aa": 3}``. Set-form behaviour falls out for inputs whose n-grams
17
+ // are all distinct.
18
+ //
19
+ // Identity / edge cases:
20
+ //
21
+ // * Two empty inputs (or both inputs shorter than ``n``) give 1.0
22
+ // — vacuously identical, matches the rapidfuzz / scikit-learn
23
+ // convention.
24
+ // * One empty and one non-empty gives 0.0.
25
+ // * ``n == 0`` is rejected at the dispatch boundary.
26
+ //
27
+ // Engine works in codepoint space throughout. The dispatch wrapper
28
+ // widens Python ``str`` storage straight out of ``PyUnicode_DATA``
29
+ // into ``std::vector<Codepoint>`` — same convention as the other
30
+ // stride-align codepoint-engine entry points.
31
+ //
32
+ // Source: standard textbook formulas. The C++ here is original.
33
+
34
+ #include <algorithm>
35
+ #include <cmath>
36
+ #include <cstddef>
37
+ #include <cstdint>
38
+ #include <cstring>
39
+ #include <string>
40
+ #include <unordered_map>
41
+ #include <vector>
42
+
43
+ namespace stride_align::ngram {
44
+
45
+ using Codepoint = std::uint32_t;
46
+
47
+ // One key per n-gram: ``n`` consecutive codepoints packed as a binary
48
+ // ``std::string`` (``n * 4`` bytes). For default ``n = 2`` and typical
49
+ // ``n = 3 / 4`` the key fits in libstdc++'s small-string-optimisation
50
+ // buffer (15 bytes inline) and the multiset insert allocates only the
51
+ // hash-map node, not the key.
52
+ using NGramKey = std::string;
53
+ using NGramMultiset = std::unordered_map<NGramKey, std::uint32_t>;
54
+
55
+ // Construct the n-gram multiset of ``input``. When ``input.size() < n``
56
+ // the multiset is empty (the "shorter than n" edge case folds into the
57
+ // empty-input branch of every metric formula).
58
+ inline NGramMultiset build_multiset(const std::vector<Codepoint>& input,
59
+ std::size_t n) {
60
+ NGramMultiset m;
61
+ if (n == 0 || input.size() < n) return m;
62
+ const std::size_t count = input.size() - n + 1;
63
+ m.reserve(count);
64
+ NGramKey key(n * sizeof(Codepoint), '\0');
65
+ for (std::size_t i = 0; i < count; ++i) {
66
+ std::memcpy(key.data(), input.data() + i, n * sizeof(Codepoint));
67
+ ++m[key];
68
+ }
69
+ return m;
70
+ }
71
+
72
+ // Aggregated stats from a one-pass comparison of two multisets. Enough
73
+ // information here to compute all four metrics without re-walking the
74
+ // inputs.
75
+ struct ComparisonStats {
76
+ std::size_t size_a = 0; // sum of count_a (i.e. |A| with multiplicity)
77
+ std::size_t size_b = 0;
78
+ std::size_t intersection = 0; // sum over keys of ``min(count_a, count_b)``
79
+ std::uint64_t dot_product = 0;// sum over keys of ``count_a · count_b``
80
+ std::uint64_t norm_a_sq = 0; // sum over keys of ``count_a²``
81
+ std::uint64_t norm_b_sq = 0;
82
+ };
83
+
84
+ inline ComparisonStats compare(const NGramMultiset& a, const NGramMultiset& b) {
85
+ ComparisonStats s;
86
+ // One pass over ``a`` covers size_a, norm_a_sq, intersection, dot.
87
+ // A separate pass over ``b`` picks up size_b and norm_b_sq; keys
88
+ // present in both sets are visited twice (once from each side) but
89
+ // contribute only to the corresponding side's totals.
90
+ for (const auto& [key, count_a] : a) {
91
+ s.size_a += count_a;
92
+ const auto ca = static_cast<std::uint64_t>(count_a);
93
+ s.norm_a_sq += ca * ca;
94
+ auto it = b.find(key);
95
+ if (it != b.end()) {
96
+ const auto count_b = it->second;
97
+ s.intersection += std::min(count_a, count_b);
98
+ s.dot_product += ca * static_cast<std::uint64_t>(count_b);
99
+ }
100
+ }
101
+ for (const auto& [_, count_b] : b) {
102
+ s.size_b += count_b;
103
+ const auto cb = static_cast<std::uint64_t>(count_b);
104
+ s.norm_b_sq += cb * cb;
105
+ }
106
+ return s;
107
+ }
108
+
109
+ // Individual metric formulas. All four follow the same identity
110
+ // convention: two empty inputs → 1.0, one empty → 0.0.
111
+
112
+ inline double jaccard_from_stats(const ComparisonStats& s) {
113
+ if (s.size_a == 0 && s.size_b == 0) return 1.0;
114
+ const std::size_t union_size = s.size_a + s.size_b - s.intersection;
115
+ if (union_size == 0) return 0.0;
116
+ return static_cast<double>(s.intersection)
117
+ / static_cast<double>(union_size);
118
+ }
119
+
120
+ inline double dice_from_stats(const ComparisonStats& s) {
121
+ if (s.size_a == 0 && s.size_b == 0) return 1.0;
122
+ const std::size_t total = s.size_a + s.size_b;
123
+ if (total == 0) return 0.0;
124
+ return 2.0 * static_cast<double>(s.intersection)
125
+ / static_cast<double>(total);
126
+ }
127
+
128
+ inline double cosine_from_stats(const ComparisonStats& s) {
129
+ if (s.size_a == 0 && s.size_b == 0) return 1.0;
130
+ if (s.norm_a_sq == 0 || s.norm_b_sq == 0) return 0.0;
131
+ const double denom = std::sqrt(static_cast<double>(s.norm_a_sq)
132
+ * static_cast<double>(s.norm_b_sq));
133
+ return static_cast<double>(s.dot_product) / denom;
134
+ }
135
+
136
+ inline double overlap_from_stats(const ComparisonStats& s) {
137
+ if (s.size_a == 0 && s.size_b == 0) return 1.0;
138
+ const std::size_t min_size = std::min(s.size_a, s.size_b);
139
+ if (min_size == 0) return 0.0;
140
+ return static_cast<double>(s.intersection)
141
+ / static_cast<double>(min_size);
142
+ }
143
+
144
+ // Convenience scalar entry points. ``n`` defaults to 2 (character
145
+ // bigrams), the rapidfuzz / scikit-learn default for character-n-gram
146
+ // similarities.
147
+
148
+ inline double jaccard(const std::vector<Codepoint>& a,
149
+ const std::vector<Codepoint>& b,
150
+ std::size_t n = 2) {
151
+ return jaccard_from_stats(compare(build_multiset(a, n),
152
+ build_multiset(b, n)));
153
+ }
154
+
155
+ inline double dice(const std::vector<Codepoint>& a,
156
+ const std::vector<Codepoint>& b,
157
+ std::size_t n = 2) {
158
+ return dice_from_stats(compare(build_multiset(a, n),
159
+ build_multiset(b, n)));
160
+ }
161
+
162
+ inline double cosine(const std::vector<Codepoint>& a,
163
+ const std::vector<Codepoint>& b,
164
+ std::size_t n = 2) {
165
+ return cosine_from_stats(compare(build_multiset(a, n),
166
+ build_multiset(b, n)));
167
+ }
168
+
169
+ inline double overlap(const std::vector<Codepoint>& a,
170
+ const std::vector<Codepoint>& b,
171
+ std::size_t n = 2) {
172
+ return overlap_from_stats(compare(build_multiset(a, n),
173
+ build_multiset(b, n)));
174
+ }
175
+
176
+ } // namespace stride_align::ngram
@@ -0,0 +1,199 @@
1
+ #pragma once
2
+
3
+ // NYSIIS — New York State Identification and Intelligence System
4
+ // phonetic encoder (Taft, 1970).
5
+ //
6
+ // Developed for cross-referencing person records, NYSIIS tends to
7
+ // produce more discriminative codes than Soundex for English-
8
+ // language names. Returns a code up to 6 characters (the classic
9
+ // length); does not truncate when the input is shorter.
10
+ //
11
+ // Algorithm steps (canonical formulation):
12
+ //
13
+ // 1. Translate the first letters:
14
+ // MAC -> MCC, KN -> NN, K -> C, PH -> FF, PF -> FF,
15
+ // SCH -> SSS
16
+ // 2. Translate the last letters:
17
+ // EE -> Y, IE -> Y, DT/RT/RD/NT/ND -> D
18
+ // 3. First character of the key is the first character of the
19
+ // transformed name.
20
+ // 4. Walk the remaining characters; for each:
21
+ // EV -> AF
22
+ // any vowel (A/E/I/O/U) -> A
23
+ // Q -> G, Z -> S, M -> N
24
+ // KN -> N, K -> C
25
+ // SCH -> SSS, PH -> FF
26
+ // H -> if neighbour is non-vowel, replace with the previous
27
+ // key character (effectively "skip")
28
+ // W -> if previous is a vowel, replace with previous letter
29
+ // 5. Skip consecutive duplicate letters in the key.
30
+ // 6. If the key ends with S, drop it.
31
+ // 7. If the key ends with AY, replace with Y.
32
+ // 8. If the key ends with A, drop it.
33
+ // 9. Truncate to length 6.
34
+ //
35
+ // Sources:
36
+ // * Taft, R. L. "Name Search Techniques." New York State
37
+ // Identification and Intelligence System, 1970.
38
+ // * Apache Commons Codec Nysiis class.
39
+ // * https://en.wikipedia.org/wiki/New_York_State_Identification_and_Intelligence_System
40
+
41
+ #include <cstddef>
42
+ #include <string>
43
+ #include <string_view>
44
+
45
+ namespace stride_align::phonetic {
46
+
47
+ namespace nysiis_detail {
48
+
49
+ inline constexpr bool is_vowel(char c) noexcept {
50
+ return c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U';
51
+ }
52
+
53
+ inline constexpr char to_upper_ascii(char c) noexcept {
54
+ return (c >= 'a' && c <= 'z') ? static_cast<char>(c - ('a' - 'A')) : c;
55
+ }
56
+
57
+ inline constexpr bool is_upper_alpha(char c) noexcept {
58
+ return c >= 'A' && c <= 'Z';
59
+ }
60
+
61
+ inline bool starts_with(std::string_view s, std::string_view prefix) noexcept {
62
+ return s.size() >= prefix.size() &&
63
+ s.compare(0, prefix.size(), prefix) == 0;
64
+ }
65
+
66
+ inline bool ends_with(std::string_view s, std::string_view suffix) noexcept {
67
+ return s.size() >= suffix.size() &&
68
+ s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
69
+ }
70
+
71
+ } // namespace nysiis_detail
72
+
73
+ inline std::string nysiis(std::string_view input) {
74
+ using namespace nysiis_detail;
75
+
76
+ // Pre-pass: keep ASCII letters only, upper-cased. No duplicate
77
+ // collapse here — NYSIIS handles that at the key-building stage.
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: first-letter translations.
87
+ if (starts_with(w, "MAC")) w.replace(0, 3, "MCC");
88
+ else if (starts_with(w, "KN")) w.replace(0, 2, "NN");
89
+ else if (w.front() == 'K') w.replace(0, 1, "C");
90
+ else if (starts_with(w, "PH")) w.replace(0, 2, "FF");
91
+ else if (starts_with(w, "PF")) w.replace(0, 2, "FF");
92
+ else if (starts_with(w, "SCH")) w.replace(0, 3, "SSS");
93
+
94
+ // Step 2: last-letter translations.
95
+ if (ends_with(w, "EE") || ends_with(w, "IE")) {
96
+ w.replace(w.size() - 2, 2, "Y");
97
+ } else if (ends_with(w, "DT") || ends_with(w, "RT") ||
98
+ ends_with(w, "RD") || ends_with(w, "NT") ||
99
+ ends_with(w, "ND")) {
100
+ w.replace(w.size() - 2, 2, "D");
101
+ }
102
+
103
+ // Step 3: key starts with the first character.
104
+ std::string key;
105
+ key.reserve(w.size());
106
+ key.push_back(w.front());
107
+
108
+ // Steps 4-5: walk remaining characters, apply translations, skip
109
+ // consecutive duplicates in the key. Multi-letter transforms
110
+ // (EV→AF, KN→N, SCH→SSS, PH→FF) consume more than one input
111
+ // letter; we track that explicitly so the input cursor advances
112
+ // past every consumed letter rather than re-scanning them.
113
+ const std::size_t n = w.size();
114
+ for (std::size_t i = 1; i < n; ++i) {
115
+ const char c = w[i];
116
+ const char prev_in_word = w[i - 1];
117
+ const char nx = (i + 1 < n) ? w[i + 1] : '\0';
118
+
119
+ std::string translated; // 0-3 chars
120
+ std::size_t consumed = 1;
121
+
122
+ if (c == 'E' && nx == 'V') {
123
+ translated = "AF";
124
+ consumed = 2;
125
+ } else if (is_vowel(c)) {
126
+ translated = "A";
127
+ } else if (c == 'Q') {
128
+ translated = "G";
129
+ } else if (c == 'Z') {
130
+ translated = "S";
131
+ } else if (c == 'M') {
132
+ translated = "N";
133
+ } else if (c == 'K' && nx == 'N') {
134
+ translated = "N";
135
+ consumed = 2;
136
+ } else if (c == 'K') {
137
+ translated = "C";
138
+ } else if (c == 'S' && nx == 'C' && i + 2 < n && w[i + 2] == 'H') {
139
+ translated = "SSS";
140
+ consumed = 3;
141
+ } else if (c == 'P' && nx == 'H') {
142
+ translated = "FF";
143
+ consumed = 2;
144
+ } else if (c == 'H') {
145
+ // Modern NYSIIS interpretation (matches jellyfish): both
146
+ // neighbours vowel → keep H; both non-vowel (or end of word)
147
+ // → replace with previous letter (effectively silent via
148
+ // key-dedup); one of each → drop H entirely.
149
+ const bool prev_v = is_vowel(prev_in_word);
150
+ const bool next_v = (nx != '\0') && is_vowel(nx);
151
+ if (prev_v && next_v) {
152
+ translated.push_back('H');
153
+ } else if (!prev_v && !next_v) {
154
+ translated.push_back(prev_in_word);
155
+ }
156
+ // else: one-of-each, drop H (translated stays empty).
157
+ } else if (c == 'W') {
158
+ // If previous is a vowel, replace with the previous letter
159
+ // (which then dedupes against the previous key character).
160
+ if (is_vowel(prev_in_word)) {
161
+ translated.push_back(prev_in_word);
162
+ } else {
163
+ translated.push_back('W');
164
+ }
165
+ } else {
166
+ translated.push_back(c);
167
+ }
168
+
169
+ // Step 5: append to key, skipping consecutive duplicates of the
170
+ // last key character. Internal duplicates inside ``translated``
171
+ // (e.g. the SSS in SCH→SSS) also collapse — both the leading
172
+ // SCH→SSS path and the mid-word case do the right thing.
173
+ for (char tc : translated) {
174
+ if (!key.empty() && key.back() == tc) continue;
175
+ key.push_back(tc);
176
+ }
177
+
178
+ // Advance i by (consumed - 1) since the for-loop ++i covers
179
+ // the first letter on its own.
180
+ i += consumed - 1;
181
+ }
182
+
183
+ // Steps 6-8: trailing-letter trim.
184
+ if (!key.empty() && key.back() == 'S') key.pop_back();
185
+ if (key.size() >= 2 && key[key.size() - 2] == 'A' && key.back() == 'Y') {
186
+ key.replace(key.size() - 2, 2, "Y");
187
+ }
188
+ if (!key.empty() && key.back() == 'A') key.pop_back();
189
+
190
+ // Truncation policy: the classical Taft 1970 paper truncates to
191
+ // six characters, but modern reference implementations (notably
192
+ // jellyfish, which is the de facto Python reference) don't. We
193
+ // follow the modern convention so cross-checks against jellyfish
194
+ // are bit-exact; callers wanting the classical six-character
195
+ // form can just ``[:6]`` the result themselves.
196
+ return key;
197
+ }
198
+
199
+ } // namespace stride_align::phonetic