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,1467 @@
1
+ // Beider-Morse Phonetic Matching engine — implementation.
2
+ //
3
+ // Linked only into the ``_generic`` backend module (see CMakeLists.txt
4
+ // ``STRIDE_ALIGN_BMPM_BACKEND`` definition). Other backend modules
5
+ // expose ``beider_morse`` through the same Python-side re-export.
6
+ //
7
+ // See ``include/stride_align/beider_morse.hpp`` for the public API and
8
+ // the algorithm summary; the source-attribution block in that header
9
+ // covers the Apache Commons Codec lineage. The Aho-Corasick trie, the
10
+ // context-predicate classifier, the bump arena, and the parser are
11
+ // original C++.
12
+
13
+ #include "stride_align/beider_morse.hpp"
14
+
15
+ #include <algorithm>
16
+ #include <array>
17
+ #include <cstdint>
18
+ #include <cstring>
19
+ #include <memory>
20
+ #include <mutex>
21
+ #include <optional>
22
+ #include <regex>
23
+ #include <stdexcept>
24
+ #include <string>
25
+ #include <string_view>
26
+ #include <unordered_map>
27
+ #include <unordered_set>
28
+ #include <utility>
29
+ #include <vector>
30
+
31
+ namespace stride_align::phonetic {
32
+
33
+ namespace {
34
+
35
+ // ----- UTF-8 / codepoint utilities ----------------------------------------
36
+
37
+ using Codepoint = std::uint32_t;
38
+ using CodepointVec = std::vector<Codepoint>;
39
+
40
+ // Decode a UTF-8 byte string into codepoints. Malformed sequences are
41
+ // replaced with U+FFFD so the rest of the engine never sees invalid
42
+ // bytes; BMPM rule files are valid UTF-8 by construction so this only
43
+ // matters for user input.
44
+ CodepointVec decode_utf8(std::string_view s) {
45
+ CodepointVec out;
46
+ out.reserve(s.size());
47
+ for (std::size_t i = 0; i < s.size();) {
48
+ const auto b0 = static_cast<std::uint8_t>(s[i]);
49
+ Codepoint cp = 0xFFFD;
50
+ std::size_t consumed = 1;
51
+ if (b0 < 0x80) {
52
+ cp = b0;
53
+ } else if ((b0 & 0xE0) == 0xC0 && i + 1 < s.size()) {
54
+ const auto b1 = static_cast<std::uint8_t>(s[i + 1]);
55
+ if ((b1 & 0xC0) == 0x80) {
56
+ cp = ((b0 & 0x1F) << 6) | (b1 & 0x3F);
57
+ consumed = 2;
58
+ }
59
+ } else if ((b0 & 0xF0) == 0xE0 && i + 2 < s.size()) {
60
+ const auto b1 = static_cast<std::uint8_t>(s[i + 1]);
61
+ const auto b2 = static_cast<std::uint8_t>(s[i + 2]);
62
+ if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80) {
63
+ cp = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F);
64
+ consumed = 3;
65
+ }
66
+ } else if ((b0 & 0xF8) == 0xF0 && i + 3 < s.size()) {
67
+ const auto b1 = static_cast<std::uint8_t>(s[i + 1]);
68
+ const auto b2 = static_cast<std::uint8_t>(s[i + 2]);
69
+ const auto b3 = static_cast<std::uint8_t>(s[i + 3]);
70
+ if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80 && (b3 & 0xC0) == 0x80) {
71
+ cp = ((b0 & 0x07) << 18) | ((b1 & 0x3F) << 12) |
72
+ ((b2 & 0x3F) << 6) | (b3 & 0x3F);
73
+ consumed = 4;
74
+ }
75
+ }
76
+ out.push_back(cp);
77
+ i += consumed;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // Append a codepoint to ``out`` as UTF-8.
83
+ void encode_utf8(std::string& out, Codepoint cp) {
84
+ if (cp < 0x80) {
85
+ out.push_back(static_cast<char>(cp));
86
+ } else if (cp < 0x800) {
87
+ out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
88
+ out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
89
+ } else if (cp < 0x10000) {
90
+ out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
91
+ out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
92
+ out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
93
+ } else {
94
+ out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
95
+ out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
96
+ out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
97
+ out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
98
+ }
99
+ }
100
+
101
+ std::string codepoints_to_utf8(const CodepointVec& v) {
102
+ std::string out;
103
+ out.reserve(v.size());
104
+ for (const auto cp : v) encode_utf8(out, cp);
105
+ return out;
106
+ }
107
+
108
+ // ASCII lower-case fold is open-coded directly at the call sites
109
+ // (public ``beider_morse`` entry and the ``Lang::guess`` per-cp loop)
110
+ // — the function was only ever called from those two places.
111
+
112
+ // ----- Language set --------------------------------------------------------
113
+
114
+ // GENERIC name type has 19 entries in ``gen_languages.txt`` (including
115
+ // ``any``). We assign bit positions in load order; ``any`` is not a
116
+ // real language but a sentinel — modelled as ``is_any`` flag.
117
+ struct LangRegistry {
118
+ std::vector<std::string> names; // index -> name (excluding "any")
119
+ std::unordered_map<std::string, int> index_of; // name -> bit index
120
+ bool finalised = false;
121
+
122
+ int add(const std::string& name) {
123
+ if (name == "any") return -1; // sentinel
124
+ auto it = index_of.find(name);
125
+ if (it != index_of.end()) return it->second;
126
+ const int idx = static_cast<int>(names.size());
127
+ names.push_back(name);
128
+ index_of.emplace(name, idx);
129
+ return idx;
130
+ }
131
+ };
132
+
133
+ struct LangSet {
134
+ std::uint32_t bits = 0;
135
+ bool any = false;
136
+
137
+ bool empty() const noexcept { return !any && bits == 0; }
138
+ bool is_any() const noexcept { return any; }
139
+ bool singleton() const noexcept {
140
+ return !any && bits != 0 && (bits & (bits - 1)) == 0;
141
+ }
142
+ int first_index() const noexcept {
143
+ if (bits == 0) return -1;
144
+ return __builtin_ctz(bits);
145
+ }
146
+
147
+ static LangSet any_set() { return {0, true}; }
148
+ static LangSet none() { return {0, false}; }
149
+
150
+ bool operator==(const LangSet& other) const noexcept {
151
+ return bits == other.bits && any == other.any;
152
+ }
153
+
154
+ LangSet restrict_to(const LangSet& other) const {
155
+ if (empty() || other.empty()) return none();
156
+ if (any) return other;
157
+ if (other.any) return *this;
158
+ LangSet r{bits & other.bits, false};
159
+ if (r.bits == 0) return none();
160
+ return r;
161
+ }
162
+
163
+ LangSet merge_with(const LangSet& other) const {
164
+ if (empty()) return other;
165
+ if (other.empty()) return *this;
166
+ if (any || other.any) return any_set();
167
+ return {bits | other.bits, false};
168
+ }
169
+ };
170
+
171
+ struct LangSetHash {
172
+ std::size_t operator()(const LangSet& s) const noexcept {
173
+ return (static_cast<std::size_t>(s.bits) << 1) | (s.any ? 1 : 0);
174
+ }
175
+ };
176
+
177
+ // Parse a "+"-separated language string ("english+french") into a LangSet.
178
+ LangSet parse_lang_set(std::string_view spec, const LangRegistry& reg) {
179
+ LangSet out = LangSet::none();
180
+ std::size_t start = 0;
181
+ while (start <= spec.size()) {
182
+ std::size_t end = spec.find('+', start);
183
+ if (end == std::string_view::npos) end = spec.size();
184
+ std::string name(spec.substr(start, end - start));
185
+ if (name == "any") {
186
+ out = out.merge_with(LangSet::any_set());
187
+ } else {
188
+ auto it = reg.index_of.find(name);
189
+ if (it != reg.index_of.end()) {
190
+ out.bits |= (1u << it->second);
191
+ }
192
+ }
193
+ if (end >= spec.size()) break;
194
+ start = end + 1;
195
+ }
196
+ return out;
197
+ }
198
+
199
+ // ----- Phoneme + PhonemeExpr (codepoint-based) ----------------------------
200
+
201
+ struct Phoneme {
202
+ CodepointVec text;
203
+ LangSet languages;
204
+ };
205
+
206
+ struct PhonemeExpr {
207
+ // A rule's right-hand side is either a single phoneme or a list of
208
+ // alternatives. We store the alternatives flat; ``size() == 1`` is
209
+ // the single-phoneme case.
210
+ std::vector<Phoneme> phonemes;
211
+ };
212
+
213
+ // ----- Resource file parsing (comment-strip + #include resolve) -----------
214
+
215
+ // Token split on whitespace, respecting double-quoted strings.
216
+ struct TokenError : std::runtime_error {
217
+ using std::runtime_error::runtime_error;
218
+ };
219
+
220
+ // Whitespace-split then strip exactly one leading and one trailing
221
+ // double quote per token. Matches Java's ``ResourceConstants.SPACES.split``
222
+ // followed by ``Rule.stripQuotes`` — neither side handles ``\"`` escapes,
223
+ // which is fine because the upstream rule files don't rely on them
224
+ // (the one ``"\""`` rule in ``gen_rules_russian.txt`` ends up with
225
+ // pattern ``\"`` in both ports, never matching real input).
226
+ std::vector<std::string> tokenise_quoted(std::string_view line) {
227
+ std::vector<std::string> out;
228
+ std::size_t i = 0;
229
+ while (i < line.size()) {
230
+ while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) ++i;
231
+ if (i >= line.size()) break;
232
+ const std::size_t start = i;
233
+ while (i < line.size() && line[i] != ' ' && line[i] != '\t') ++i;
234
+ std::string token(line.substr(start, i - start));
235
+ if (!token.empty() && token.front() == '"') token.erase(0, 1);
236
+ if (!token.empty() && token.back() == '"') token.pop_back();
237
+ out.push_back(std::move(token));
238
+ }
239
+ return out;
240
+ }
241
+
242
+ // Strip block comments (``/* ... */``) and end-of-line ``// ...`` comments,
243
+ // returning the cleaned source as a single string. The vendored rule files
244
+ // have only one block comment (the ASF header at the top), but a handful
245
+ // have inline blocks too.
246
+ std::string strip_comments(std::string_view src) {
247
+ std::string out;
248
+ out.reserve(src.size());
249
+ std::size_t i = 0;
250
+ while (i < src.size()) {
251
+ if (i + 1 < src.size() && src[i] == '/' && src[i + 1] == '*') {
252
+ const std::size_t close = src.find("*/", i + 2);
253
+ if (close == std::string_view::npos) break;
254
+ i = close + 2;
255
+ continue;
256
+ }
257
+ if (i + 1 < src.size() && src[i] == '/' && src[i + 1] == '/') {
258
+ while (i < src.size() && src[i] != '\n') ++i;
259
+ continue;
260
+ }
261
+ out.push_back(src[i]);
262
+ ++i;
263
+ }
264
+ return out;
265
+ }
266
+
267
+ // Split into trimmed, non-empty lines.
268
+ std::vector<std::string> split_lines(std::string_view src) {
269
+ std::vector<std::string> out;
270
+ std::size_t i = 0;
271
+ while (i < src.size()) {
272
+ std::size_t end = src.find('\n', i);
273
+ if (end == std::string_view::npos) end = src.size();
274
+ std::string_view line = src.substr(i, end - i);
275
+ while (!line.empty() && (line.front() == ' ' || line.front() == '\t' || line.front() == '\r')) {
276
+ line.remove_prefix(1);
277
+ }
278
+ while (!line.empty() && (line.back() == ' ' || line.back() == '\t' || line.back() == '\r')) {
279
+ line.remove_suffix(1);
280
+ }
281
+ if (!line.empty()) out.emplace_back(line);
282
+ i = end + 1;
283
+ }
284
+ return out;
285
+ }
286
+
287
+ // ----- Context predicate classification -----------------------------------
288
+
289
+ // Pre-classified left-context / right-context predicate. Built at static
290
+ // init; matched in the hot loop without touching ``std::regex`` for the
291
+ // common cases that profile data showed cover the rule files.
292
+ struct ContextPred {
293
+ enum class Kind : std::uint8_t {
294
+ kAlways, // empty
295
+ kStartAnchor, // lcontext only: matches iff pos == 0
296
+ kEndAnchor, // rcontext only: matches iff pos+plen == input.size()
297
+ kCharClass, // single codepoint at the adjacent position in set
298
+ kNegCharClass, // single codepoint at the adjacent position NOT in set
299
+ kAnchoredCharClass, // start-anchored (l) or end-anchored (r) + class
300
+ kAnchoredNegCharClass,
301
+ kLiteralEnd, // lcontext: input ends with literal
302
+ kLiteralStart, // rcontext: input starts at pos+plen with literal
303
+ kAnchoredLiteral, // exact-match of input slice to literal
304
+ kRegex, // fallback
305
+ };
306
+
307
+ Kind kind = Kind::kAlways;
308
+ bool is_left = false; // lcontext (vs rcontext)
309
+ std::unordered_set<Codepoint> char_set;
310
+ CodepointVec literal;
311
+ std::shared_ptr<std::regex> regex;
312
+
313
+ // Match against ``input[0..pos)`` (lcontext) or ``input[pos..)``
314
+ // (rcontext, where ``pos`` is already advanced past the matched
315
+ // pattern). All matching happens in codepoint space; the ``kRegex``
316
+ // fallback lazy-encodes the relevant codepoint slice to UTF-8 only
317
+ // when it is actually invoked (a small minority of context
318
+ // predicates after classification).
319
+ bool match(const CodepointVec& input, std::size_t pos) const;
320
+ };
321
+
322
+ // Parse a ``[...]`` char-class body (after the ``[``, before the ``]``).
323
+ // Returns the codepoint set; ``negated`` is set if the body starts with
324
+ // ``^``.
325
+ std::unordered_set<Codepoint> parse_char_class(std::string_view body, bool& negated) {
326
+ negated = false;
327
+ if (!body.empty() && body.front() == '^') {
328
+ negated = true;
329
+ body.remove_prefix(1);
330
+ }
331
+ const CodepointVec cps = decode_utf8(body);
332
+ return std::unordered_set<Codepoint>(cps.begin(), cps.end());
333
+ }
334
+
335
+ // Returns true if the regex-like fragment contains any character that
336
+ // is meaningful in a Java/PCRE regex (other than the constructs we
337
+ // already classify — ``^``, ``$``, single ``[...]`` at start/end).
338
+ bool needs_regex_fallback(std::string_view body) {
339
+ // The classifier handles: empty, ^, $, single literal, [class],
340
+ // [class]$, ^[class], ^[class]$, literal$, ^literal, ^literal$.
341
+ // Anything else (alternation '|', '(', '*', '+', '?', escapes,
342
+ // multiple char classes) goes to std::regex.
343
+ bool seen_open = false;
344
+ bool seen_close_at_end = false;
345
+ for (std::size_t i = 0; i < body.size(); ++i) {
346
+ const char c = body[i];
347
+ if (c == '[') {
348
+ if (seen_open) return true;
349
+ seen_open = true;
350
+ continue;
351
+ }
352
+ if (c == ']') {
353
+ if (i != body.size() - 1 && !(i == body.size() - 2 && body.back() == '$')) {
354
+ return true;
355
+ }
356
+ seen_close_at_end = true;
357
+ continue;
358
+ }
359
+ if (c == '(' || c == '|' || c == '*' || c == '+' || c == '?' ||
360
+ c == '\\' || c == '.') {
361
+ return true;
362
+ }
363
+ }
364
+ (void)seen_close_at_end;
365
+ return false;
366
+ }
367
+
368
+ ContextPred classify_context(std::string_view raw, bool is_left) {
369
+ ContextPred p;
370
+ p.is_left = is_left;
371
+
372
+ if (raw.empty()) {
373
+ p.kind = ContextPred::Kind::kAlways;
374
+ return p;
375
+ }
376
+ if (raw == "^") {
377
+ p.kind = is_left ? ContextPred::Kind::kStartAnchor
378
+ : ContextPred::Kind::kAlways;
379
+ return p;
380
+ }
381
+ if (raw == "$") {
382
+ p.kind = is_left ? ContextPred::Kind::kAlways
383
+ : ContextPred::Kind::kEndAnchor;
384
+ return p;
385
+ }
386
+
387
+ const bool start_anchor = !raw.empty() && raw.front() == '^';
388
+ const bool end_anchor = !raw.empty() && raw.back() == '$';
389
+ std::string_view body = raw;
390
+ if (start_anchor) body.remove_prefix(1);
391
+ if (end_anchor) body.remove_suffix(1);
392
+
393
+ if (needs_regex_fallback(body)) {
394
+ p.kind = ContextPred::Kind::kRegex;
395
+ // Java wraps with anchors:
396
+ // lcontext -> ``raw + "$"`` against ``input[0..pos)``
397
+ // rcontext -> ``"^" + raw`` against ``input[pos..)``
398
+ // We mirror that on the byte-level input slice.
399
+ std::string compiled;
400
+ if (is_left) {
401
+ compiled.append(raw);
402
+ compiled.append("$");
403
+ } else {
404
+ compiled.append("^");
405
+ compiled.append(raw);
406
+ }
407
+ try {
408
+ p.regex = std::make_shared<std::regex>(
409
+ compiled, std::regex::ECMAScript | std::regex::optimize);
410
+ } catch (const std::regex_error&) {
411
+ p.kind = ContextPred::Kind::kAlways; // safest fallback
412
+ }
413
+ return p;
414
+ }
415
+
416
+ // Single ``[...]`` body, possibly anchored on one side.
417
+ if (!body.empty() && body.front() == '[' && body.back() == ']') {
418
+ bool negated = false;
419
+ p.char_set = parse_char_class(body.substr(1, body.size() - 2), negated);
420
+ if (start_anchor || end_anchor) {
421
+ p.kind = negated ? ContextPred::Kind::kAnchoredNegCharClass
422
+ : ContextPred::Kind::kAnchoredCharClass;
423
+ } else {
424
+ p.kind = negated ? ContextPred::Kind::kNegCharClass
425
+ : ContextPred::Kind::kCharClass;
426
+ }
427
+ return p;
428
+ }
429
+
430
+ // Pure literal, possibly anchored.
431
+ p.literal = decode_utf8(body);
432
+ if (start_anchor && end_anchor) {
433
+ p.kind = ContextPred::Kind::kAnchoredLiteral;
434
+ } else if (is_left) {
435
+ p.kind = end_anchor ? ContextPred::Kind::kLiteralEnd
436
+ : ContextPred::Kind::kLiteralEnd;
437
+ } else {
438
+ p.kind = start_anchor ? ContextPred::Kind::kLiteralStart
439
+ : ContextPred::Kind::kLiteralStart;
440
+ }
441
+ return p;
442
+ }
443
+
444
+ bool ContextPred::match(const CodepointVec& input,
445
+ std::size_t pos) const {
446
+ switch (kind) {
447
+ case Kind::kAlways:
448
+ return true;
449
+ case Kind::kStartAnchor:
450
+ return is_left ? (pos == 0) : true;
451
+ case Kind::kEndAnchor:
452
+ return !is_left && pos == input.size();
453
+ case Kind::kCharClass: {
454
+ if (is_left) {
455
+ if (pos == 0) return false;
456
+ return char_set.count(input[pos - 1]) > 0;
457
+ }
458
+ if (pos >= input.size()) return false;
459
+ return char_set.count(input[pos]) > 0;
460
+ }
461
+ case Kind::kNegCharClass: {
462
+ if (is_left) {
463
+ if (pos == 0) return false;
464
+ return char_set.count(input[pos - 1]) == 0;
465
+ }
466
+ if (pos >= input.size()) return false;
467
+ return char_set.count(input[pos]) == 0;
468
+ }
469
+ case Kind::kAnchoredCharClass: {
470
+ // ``^[abc]`` (left) -> input is exactly one cp from set, i.e. pos == 1.
471
+ // ``[abc]$`` (right) -> input has exactly one remaining cp from set.
472
+ if (is_left) {
473
+ return pos == 1 && char_set.count(input[0]) > 0;
474
+ }
475
+ return pos + 1 == input.size() && char_set.count(input[pos]) > 0;
476
+ }
477
+ case Kind::kAnchoredNegCharClass: {
478
+ if (is_left) {
479
+ return pos == 1 && char_set.count(input[0]) == 0;
480
+ }
481
+ return pos + 1 == input.size() && char_set.count(input[pos]) == 0;
482
+ }
483
+ case Kind::kLiteralEnd: {
484
+ // lcontext: input[0..pos) ends with literal
485
+ if (literal.size() > pos) return false;
486
+ for (std::size_t k = 0; k < literal.size(); ++k) {
487
+ if (input[pos - literal.size() + k] != literal[k]) return false;
488
+ }
489
+ return true;
490
+ }
491
+ case Kind::kLiteralStart: {
492
+ // rcontext: input[pos..) starts with literal
493
+ if (pos + literal.size() > input.size()) return false;
494
+ for (std::size_t k = 0; k < literal.size(); ++k) {
495
+ if (input[pos + k] != literal[k]) return false;
496
+ }
497
+ return true;
498
+ }
499
+ case Kind::kAnchoredLiteral: {
500
+ if (is_left) {
501
+ return pos == literal.size() &&
502
+ std::equal(literal.begin(), literal.end(), input.begin());
503
+ }
504
+ return pos + literal.size() == input.size() &&
505
+ std::equal(literal.begin(), literal.end(), input.begin() + pos);
506
+ }
507
+ case Kind::kRegex: {
508
+ // Lazy-encode the relevant codepoint slice to UTF-8 — std::regex
509
+ // is byte-only, and the upstream lang.txt / context regex
510
+ // patterns are written in UTF-8. The classifier routes the vast
511
+ // majority of context predicates to the constant-time kinds
512
+ // above, so this allocation is rare in practice.
513
+ if (!regex) return false;
514
+ std::string slice;
515
+ if (is_left) {
516
+ slice.reserve(pos * 2);
517
+ for (std::size_t i = 0; i < pos; ++i) encode_utf8(slice, input[i]);
518
+ } else {
519
+ slice.reserve((input.size() - pos) * 2);
520
+ for (std::size_t i = pos; i < input.size(); ++i) {
521
+ encode_utf8(slice, input[i]);
522
+ }
523
+ }
524
+ return std::regex_search(slice, *regex);
525
+ }
526
+ }
527
+ return false;
528
+ }
529
+
530
+ // ----- Rule ----------------------------------------------------------------
531
+
532
+ struct Rule {
533
+ CodepointVec pattern; // already lowered / decoded
534
+ ContextPred lcontext;
535
+ ContextPred rcontext;
536
+ PhonemeExpr phoneme_expr;
537
+ };
538
+
539
+ // ----- PhonemeExpr parsing -------------------------------------------------
540
+
541
+ // Parse one phoneme token (possibly with ``[langs]`` suffix) into a Phoneme.
542
+ Phoneme parse_phoneme_token(std::string_view tok, const LangRegistry& reg) {
543
+ // ``ph[lang1+lang2]`` -> text=ph, langs={lang1, lang2}.
544
+ // No ``[`` -> any language.
545
+ const auto open = tok.find('[');
546
+ if (open == std::string_view::npos) {
547
+ return {decode_utf8(tok), LangSet::any_set()};
548
+ }
549
+ if (tok.back() != ']') {
550
+ return {decode_utf8(tok), LangSet::any_set()};
551
+ }
552
+ const auto before = tok.substr(0, open);
553
+ const auto inner = tok.substr(open + 1, tok.size() - open - 2);
554
+ return {decode_utf8(before), parse_lang_set(inner, reg)};
555
+ }
556
+
557
+ // Parse a phoneme expression: either a single ``"x"`` token or a
558
+ // parenthesised list ``"(a|b|c)"``. Mirrors Java
559
+ // ``Rule.parsePhonemeExpr`` including the trailing-pipe -> empty
560
+ // phoneme edge case (Java only checks leading/trailing pipe with
561
+ // ``length() != 0`` on the surviving side; we replicate that exactly).
562
+ PhonemeExpr parse_phoneme_expr(std::string_view src, const LangRegistry& reg) {
563
+ PhonemeExpr expr;
564
+ if (!src.empty() && src.front() == '(') {
565
+ if (src.back() != ')') {
566
+ throw std::runtime_error(
567
+ std::string("phoneme expression missing ')': ") + std::string(src));
568
+ }
569
+ const auto body = src.substr(1, src.size() - 2);
570
+ std::vector<std::string_view> parts;
571
+ std::size_t start = 0;
572
+ for (std::size_t i = 0; i <= body.size(); ++i) {
573
+ if (i == body.size() || body[i] == '|') {
574
+ parts.push_back(body.substr(start, i - start));
575
+ start = i + 1;
576
+ }
577
+ }
578
+ expr.phonemes.reserve(parts.size());
579
+ for (auto p : parts) expr.phonemes.push_back(parse_phoneme_token(p, reg));
580
+ const bool first_nonempty_with_leading_pipe =
581
+ parts.size() > 1 && !parts.front().empty() && body.front() == '|';
582
+ const bool last_nonempty_with_trailing_pipe =
583
+ !parts.empty() && !parts.back().empty() && body.back() == '|';
584
+ if (first_nonempty_with_leading_pipe || last_nonempty_with_trailing_pipe) {
585
+ expr.phonemes.push_back({CodepointVec(), LangSet::any_set()});
586
+ }
587
+ return expr;
588
+ }
589
+ expr.phonemes.push_back(parse_phoneme_token(src, reg));
590
+ return expr;
591
+ }
592
+
593
+ // ----- Aho-Corasick trie ---------------------------------------------------
594
+
595
+ // ----- Resource parser: ``parseRules`` equivalent -------------------------
596
+
597
+ struct ParsedRules {
598
+ // pattern length, rule — we process longest-first when matching at a
599
+ // position to pick the best match.
600
+ std::vector<Rule> rules;
601
+ };
602
+
603
+ class ResourceMap {
604
+ public:
605
+ explicit ResourceMap(const std::unordered_map<std::string, std::string>& src)
606
+ : src_(src) {}
607
+
608
+ // Returns the raw content for ``name``, or throws.
609
+ const std::string& get(const std::string& name) const {
610
+ auto it = src_.find(name);
611
+ if (it == src_.end()) {
612
+ throw std::runtime_error("BMPM resource missing: " + name);
613
+ }
614
+ return it->second;
615
+ }
616
+
617
+ bool has(const std::string& name) const { return src_.find(name) != src_.end(); }
618
+
619
+ private:
620
+ const std::unordered_map<std::string, std::string>& src_;
621
+ };
622
+
623
+ ParsedRules parse_rules(const ResourceMap& resources,
624
+ const std::string& name,
625
+ const LangRegistry& reg,
626
+ std::unordered_set<std::string>& visited) {
627
+ ParsedRules out;
628
+ if (visited.count(name)) return out;
629
+ visited.insert(name);
630
+
631
+ const std::string& raw = resources.get(name);
632
+ const std::string stripped = strip_comments(raw);
633
+ for (const auto& line : split_lines(stripped)) {
634
+ static constexpr std::string_view kInclude = "#include";
635
+ if (line.rfind(std::string(kInclude), 0) == 0) {
636
+ auto rest = std::string_view(line).substr(kInclude.size());
637
+ while (!rest.empty() && (rest.front() == ' ' || rest.front() == '\t')) {
638
+ rest.remove_prefix(1);
639
+ }
640
+ auto included = parse_rules(resources, std::string(rest), reg, visited);
641
+ out.rules.insert(out.rules.end(),
642
+ std::make_move_iterator(included.rules.begin()),
643
+ std::make_move_iterator(included.rules.end()));
644
+ continue;
645
+ }
646
+ std::vector<std::string> parts;
647
+ try {
648
+ parts = tokenise_quoted(line);
649
+ } catch (const TokenError& e) {
650
+ throw std::runtime_error(std::string("BMPM rule parse error in ") +
651
+ name + ": " + e.what());
652
+ }
653
+ if (parts.size() != 4) {
654
+ throw std::runtime_error("BMPM rule must have 4 quoted fields in " +
655
+ name + ": " + line);
656
+ }
657
+ Rule r;
658
+ // Rule patterns are stored case-sensitive: the main-rules files use
659
+ // lowercase patterns matching the lowercased user input, but the
660
+ // approx/exact final-rules files use uppercase BMPM phoneme markers
661
+ // (``"O" "" "" "o"``, ``"E" "" "" "e"``, etc.). Lower-casing here
662
+ // would silently nullify ~260 normalisation rules across the
663
+ // gen_approx_* and gen_exact_* files.
664
+ r.pattern = decode_utf8(parts[0]);
665
+ r.lcontext = classify_context(parts[1], /*is_left=*/true);
666
+ r.rcontext = classify_context(parts[2], /*is_left=*/false);
667
+ r.phoneme_expr = parse_phoneme_expr(parts[3], reg);
668
+ out.rules.push_back(std::move(r));
669
+ }
670
+ return out;
671
+ }
672
+
673
+ ParsedRules parse_rules(const ResourceMap& resources,
674
+ const std::string& name,
675
+ const LangRegistry& reg) {
676
+ std::unordered_set<std::string> visited;
677
+ return parse_rules(resources, name, reg, visited);
678
+ }
679
+
680
+ // ----- Lang language-guesser ----------------------------------------------
681
+
682
+ struct LangRule {
683
+ std::regex pattern;
684
+ std::uint32_t lang_bits = 0;
685
+ bool langs_any = false;
686
+ bool accept_on_match = true;
687
+ };
688
+
689
+ struct Lang {
690
+ std::vector<LangRule> rules;
691
+ std::uint32_t all_bits = 0; // all real languages in the registry
692
+
693
+ // The lang.txt patterns are written in UTF-8 (Romanian / Polish
694
+ // diacritics, Greek letters, smart quotes), so the regex match
695
+ // is done against a one-off UTF-8 encoding of the codepoint slice.
696
+ // This runs at most once per top-level encode call plus once per
697
+ // recursive prefix split, so the allocation is not in the per-rule
698
+ // hot path.
699
+ LangSet guess(const CodepointVec& input) const {
700
+ std::string lowered;
701
+ lowered.reserve(input.size() * 2);
702
+ for (auto cp : input) {
703
+ const Codepoint lc = (cp >= 'A' && cp <= 'Z') ? cp + 32 : cp;
704
+ encode_utf8(lowered, lc);
705
+ }
706
+ std::uint32_t live = all_bits;
707
+ for (const auto& r : rules) {
708
+ if (std::regex_search(lowered, r.pattern)) {
709
+ if (r.accept_on_match) {
710
+ live &= r.lang_bits;
711
+ } else {
712
+ live &= ~r.lang_bits;
713
+ }
714
+ }
715
+ }
716
+ if (live == 0) return LangSet::any_set();
717
+ return LangSet{live, false};
718
+ }
719
+ };
720
+
721
+ Lang parse_lang(const ResourceMap& resources,
722
+ const std::string& name,
723
+ const LangRegistry& reg) {
724
+ Lang out;
725
+ out.all_bits = (reg.names.empty() ? 0u : ((1u << reg.names.size()) - 1));
726
+ const std::string& raw = resources.get(name);
727
+ const std::string stripped = strip_comments(raw);
728
+ for (const auto& line : split_lines(stripped)) {
729
+ // Three whitespace-separated fields. Java uses ``\s+``.
730
+ std::vector<std::string_view> tokens;
731
+ std::size_t i = 0;
732
+ while (i < line.size()) {
733
+ while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) ++i;
734
+ if (i >= line.size()) break;
735
+ const std::size_t start = i;
736
+ while (i < line.size() && line[i] != ' ' && line[i] != '\t') ++i;
737
+ tokens.push_back(std::string_view(line).substr(start, i - start));
738
+ }
739
+ if (tokens.size() != 3) continue;
740
+ LangRule r;
741
+ try {
742
+ r.pattern = std::regex(std::string(tokens[0]),
743
+ std::regex::ECMAScript | std::regex::optimize);
744
+ } catch (const std::regex_error&) {
745
+ continue;
746
+ }
747
+ LangSet langs = parse_lang_set(tokens[1], reg);
748
+ r.lang_bits = langs.bits;
749
+ r.langs_any = langs.any;
750
+ r.accept_on_match = (tokens[2] == "true");
751
+ out.rules.push_back(std::move(r));
752
+ }
753
+ return out;
754
+ }
755
+
756
+ // ----- Final rules: lookup map (language -> rule list) --------------------
757
+
758
+ struct FinalRules {
759
+ std::unordered_map<std::string, std::vector<Rule>> by_language;
760
+ };
761
+
762
+ // ----- Compiled tables singleton ------------------------------------------
763
+
764
+ struct BmpmTables {
765
+ LangRegistry registry;
766
+ std::unordered_map<std::string, std::vector<Rule>> main_rules; // lang -> rules
767
+ std::unordered_map<std::string, std::vector<Rule>> approx_rules;
768
+ std::unordered_map<std::string, std::vector<Rule>> exact_rules;
769
+ Lang lang;
770
+ };
771
+
772
+ std::unique_ptr<BmpmTables> g_tables;
773
+ std::once_flag g_tables_once;
774
+ std::mutex g_pending_mutex;
775
+ std::unique_ptr<std::unordered_map<std::string, std::string>> g_pending_resources;
776
+
777
+ void load_registry(const ResourceMap& res, LangRegistry& reg) {
778
+ const std::string& raw = res.get("gen_languages");
779
+ const std::string stripped = strip_comments(raw);
780
+ for (const auto& line : split_lines(stripped)) {
781
+ reg.add(line);
782
+ }
783
+ reg.finalised = true;
784
+ }
785
+
786
+ std::vector<Rule> rules_for(const ResourceMap& res,
787
+ const std::string& prefix,
788
+ const std::string& lang,
789
+ const LangRegistry& reg) {
790
+ return parse_rules(res, prefix + "_" + lang, reg).rules;
791
+ }
792
+
793
+ std::unique_ptr<BmpmTables> build_tables(
794
+ const std::unordered_map<std::string, std::string>& src) {
795
+ auto tables = std::make_unique<BmpmTables>();
796
+ ResourceMap res(src);
797
+ load_registry(res, tables->registry);
798
+
799
+ const auto& reg = tables->registry;
800
+ std::vector<std::string> languages_to_load = reg.names;
801
+ languages_to_load.push_back("any");
802
+
803
+ for (const auto& lang : languages_to_load) {
804
+ tables->main_rules[lang] = rules_for(res, "gen_rules", lang, reg);
805
+ }
806
+ for (const auto& lang : languages_to_load) {
807
+ tables->approx_rules[lang] = rules_for(res, "gen_approx", lang, reg);
808
+ tables->exact_rules[lang] = rules_for(res, "gen_exact", lang, reg);
809
+ }
810
+ if (res.has("gen_approx_common")) {
811
+ tables->approx_rules["common"] =
812
+ parse_rules(res, "gen_approx_common", reg).rules;
813
+ }
814
+ if (res.has("gen_exact_common")) {
815
+ tables->exact_rules["common"] =
816
+ parse_rules(res, "gen_exact_common", reg).rules;
817
+ }
818
+
819
+ tables->lang = parse_lang(res, "gen_lang", reg);
820
+ return tables;
821
+ }
822
+
823
+ void init_tables_if_needed() {
824
+ std::call_once(g_tables_once, []() {
825
+ std::unique_ptr<std::unordered_map<std::string, std::string>> pending;
826
+ {
827
+ std::lock_guard<std::mutex> lock(g_pending_mutex);
828
+ pending = std::move(g_pending_resources);
829
+ }
830
+ if (!pending) {
831
+ throw std::runtime_error(
832
+ "BMPM resources have not been registered — call "
833
+ "bmpm_register_resources() first");
834
+ }
835
+ g_tables = build_tables(*pending);
836
+ });
837
+ }
838
+
839
+ // ----- PhonemeBuilder: arena-allocated set --------------------------------
840
+
841
+ // Per-call bump arena. Phoneme texts get appended into ``buffer``; the
842
+ // vector of (offset, length, langset) tuples is the live phoneme set.
843
+ // We do not deduplicate by text identity for the duration of the encode
844
+ // loop — duplicates inside a single ``apply`` are kept because their
845
+ // LangSets differ; at the end we collapse them with a LangSet merge.
846
+ struct ArenaPhoneme {
847
+ std::uint32_t offset;
848
+ std::uint32_t length;
849
+ LangSet langs;
850
+ };
851
+
852
+ struct PhonemeArena {
853
+ std::vector<Codepoint> buffer;
854
+ std::vector<ArenaPhoneme> phonemes;
855
+ std::vector<ArenaPhoneme> scratch;
856
+
857
+ void reset() {
858
+ buffer.clear();
859
+ phonemes.clear();
860
+ scratch.clear();
861
+ }
862
+
863
+ std::uint32_t append(const CodepointVec& text) {
864
+ const auto off = static_cast<std::uint32_t>(buffer.size());
865
+ buffer.insert(buffer.end(), text.begin(), text.end());
866
+ return off;
867
+ }
868
+
869
+ void start_with_languages(const LangSet& langs) {
870
+ phonemes.clear();
871
+ phonemes.push_back({0, 0, langs});
872
+ }
873
+
874
+ // Apply a phoneme expression to every live phoneme: Cartesian product
875
+ // restricted by language-set intersection.
876
+ //
877
+ // The buffer is grown once up-front to the worst-case combined size
878
+ // so the per-rule-fire ``buffer.insert(buffer.end(), buffer.begin()+x,
879
+ // buffer.begin()+y)`` self-insert no longer needs the temporary
880
+ // ``left_copy`` it used to allocate to dodge iterator invalidation
881
+ // — after the reserve there is no realloc, so source iterators stay
882
+ // valid.
883
+ void apply(const PhonemeExpr& expr, std::size_t max_phonemes) {
884
+ scratch.clear();
885
+ scratch.reserve(std::min(phonemes.size() * expr.phonemes.size(),
886
+ max_phonemes));
887
+ std::size_t worst_case_growth = 0;
888
+ for (const auto& left : phonemes) {
889
+ for (const auto& right : expr.phonemes) {
890
+ if (right.text.empty()) continue; // shares left's offset
891
+ worst_case_growth += left.length + right.text.size();
892
+ }
893
+ }
894
+ if (worst_case_growth != 0) {
895
+ buffer.reserve(buffer.size() + worst_case_growth);
896
+ }
897
+ for (const auto& left : phonemes) {
898
+ for (const auto& right : expr.phonemes) {
899
+ const LangSet joined = left.langs.restrict_to(right.languages);
900
+ if (joined.empty()) continue;
901
+ ArenaPhoneme combined;
902
+ combined.langs = joined;
903
+ if (right.text.empty()) {
904
+ combined.offset = left.offset;
905
+ combined.length = left.length;
906
+ } else {
907
+ combined.offset = static_cast<std::uint32_t>(buffer.size());
908
+ buffer.insert(buffer.end(),
909
+ buffer.begin() + left.offset,
910
+ buffer.begin() + left.offset + left.length);
911
+ buffer.insert(buffer.end(), right.text.begin(), right.text.end());
912
+ combined.length = left.length + static_cast<std::uint32_t>(right.text.size());
913
+ }
914
+ if (scratch.size() < max_phonemes) {
915
+ scratch.push_back(combined);
916
+ } else {
917
+ goto done;
918
+ }
919
+ }
920
+ }
921
+ done:
922
+ phonemes.swap(scratch);
923
+ }
924
+
925
+ // Append ``tail`` to every live phoneme. Same pre-reserve idiom as
926
+ // ``apply`` so the per-phoneme self-insert avoids the slice copy.
927
+ void append_literal(const CodepointVec& tail) {
928
+ if (tail.empty()) return;
929
+ std::size_t growth = 0;
930
+ for (const auto& ph : phonemes) growth += ph.length + tail.size();
931
+ buffer.reserve(buffer.size() + growth);
932
+ for (auto& ph : phonemes) {
933
+ const auto new_off = static_cast<std::uint32_t>(buffer.size());
934
+ buffer.insert(buffer.end(),
935
+ buffer.begin() + ph.offset,
936
+ buffer.begin() + ph.offset + ph.length);
937
+ buffer.insert(buffer.end(), tail.begin(), tail.end());
938
+ ph.offset = new_off;
939
+ ph.length += static_cast<std::uint32_t>(tail.size());
940
+ }
941
+ }
942
+ };
943
+
944
+ // Render the arena's live phoneme set to a ``|``-joined UTF-8 string.
945
+ // Deduplicates by phoneme text (merging language sets of duplicates,
946
+ // matching Java's ``applyFinalRules`` merge step) and sorts the result
947
+ // by phoneme text — Java does this implicitly because
948
+ // ``applyFinalRules`` stores phonemes in a ``TreeMap<Phoneme, Phoneme,
949
+ // Phoneme.COMPARATOR>`` whose ``keySet()`` iterates in sorted order.
950
+ // Sorting on the C++ side gives byte-identical output to the upstream.
951
+ std::string render_arena(const PhonemeArena& a) {
952
+ struct Group { CodepointVec text; LangSet langs; };
953
+ std::vector<Group> groups;
954
+ groups.reserve(a.phonemes.size());
955
+ for (const auto& ph : a.phonemes) {
956
+ CodepointVec text(a.buffer.begin() + ph.offset,
957
+ a.buffer.begin() + ph.offset + ph.length);
958
+ bool merged = false;
959
+ for (auto& g : groups) {
960
+ if (g.text == text) {
961
+ g.langs = g.langs.merge_with(ph.langs);
962
+ merged = true;
963
+ break;
964
+ }
965
+ }
966
+ if (!merged) groups.push_back({std::move(text), ph.langs});
967
+ }
968
+ std::sort(groups.begin(), groups.end(),
969
+ [](const Group& a, const Group& b) { return a.text < b.text; });
970
+ std::string out;
971
+ for (std::size_t i = 0; i < groups.size(); ++i) {
972
+ if (i != 0) out.push_back('|');
973
+ out.append(codepoints_to_utf8(groups[i].text));
974
+ }
975
+ return out;
976
+ }
977
+
978
+ // ----- Rule lookup at a position ------------------------------------------
979
+ //
980
+ // Aho-Corasick trie over the per-(name-type, rule-type, language) rule
981
+ // pattern set. The trie is built once at static-init via ``build_trie``;
982
+ // failure links are computed by a single BFS sweep so the data
983
+ // structure is a proper Aho-Corasick automaton, not just a longest-
984
+ // prefix trie. BMPM's per-call ``find_rule`` walks the trie from root
985
+ // for the input at ``pos`` (since the surrounding loop advances by the
986
+ // matched pattern's length each step, a continuous failure-link scan
987
+ // across positions does not apply). The failure links are still
988
+ // available for callers that want classic AC continuous-scan
989
+ // semantics.
990
+ //
991
+ // The algorithm description is from Aho, A.V. & Corasick, M.J. (1975),
992
+ // "Efficient string matching: An aid to bibliographic search",
993
+ // Communications of the ACM 18(6) — the implementation below is
994
+ // original C++.
995
+
996
+ struct AhoNode {
997
+ // Children indexed by codepoint. ``unordered_map`` rather than a
998
+ // dense array because the alphabet is the full Unicode range used by
999
+ // the GENERIC BMPM rule files (Latin + Cyrillic + Greek + Polish
1000
+ // diacritics); a dense [0, 256) table would not cover it.
1001
+ std::unordered_map<Codepoint, std::uint32_t> children;
1002
+ // Failure link — 0 means root. Set during ``build_failures``.
1003
+ std::uint32_t failure = 0;
1004
+ // Rules whose pattern ends at this node, in file-of-rules order.
1005
+ // Multiple rules can share a pattern (different lcontext / rcontext
1006
+ // / phoneme expression); they appear here in insertion order.
1007
+ std::vector<std::uint32_t> rule_ids;
1008
+ };
1009
+
1010
+ struct RuleTrie {
1011
+ std::vector<AhoNode> nodes; // nodes[0] is the root
1012
+ std::vector<Rule> rules;
1013
+ std::size_t max_pattern_length = 0;
1014
+ bool empty = true;
1015
+ };
1016
+
1017
+ void trie_add_rule(RuleTrie& t, Rule r) {
1018
+ if (t.nodes.empty()) t.nodes.emplace_back(); // root
1019
+ std::uint32_t cur = 0;
1020
+ for (const Codepoint cp : r.pattern) {
1021
+ auto it = t.nodes[cur].children.find(cp);
1022
+ if (it == t.nodes[cur].children.end()) {
1023
+ t.nodes.emplace_back();
1024
+ const auto next = static_cast<std::uint32_t>(t.nodes.size() - 1);
1025
+ t.nodes[cur].children.emplace(cp, next);
1026
+ cur = next;
1027
+ } else {
1028
+ cur = it->second;
1029
+ }
1030
+ }
1031
+ const auto rule_id = static_cast<std::uint32_t>(t.rules.size());
1032
+ t.nodes[cur].rule_ids.push_back(rule_id);
1033
+ if (r.pattern.size() > t.max_pattern_length) {
1034
+ t.max_pattern_length = r.pattern.size();
1035
+ }
1036
+ t.rules.push_back(std::move(r));
1037
+ t.empty = false;
1038
+ }
1039
+
1040
+ // Set every node's ``failure`` to the index of the deepest proper
1041
+ // suffix of its path that is itself a prefix of some pattern. Root's
1042
+ // failure is itself (encoded as 0). Children of root all fail to root.
1043
+ // Standard BFS construction.
1044
+ void build_failures(RuleTrie& t) {
1045
+ if (t.nodes.empty()) return;
1046
+ std::vector<std::uint32_t> queue;
1047
+ queue.reserve(t.nodes.size());
1048
+ for (auto& [cp, child] : t.nodes[0].children) {
1049
+ t.nodes[child].failure = 0;
1050
+ queue.push_back(child);
1051
+ }
1052
+ for (std::size_t qi = 0; qi < queue.size(); ++qi) {
1053
+ const auto u = queue[qi];
1054
+ for (auto& [cp, v] : t.nodes[u].children) {
1055
+ // Walk failure links from u's failure looking for a node with
1056
+ // an edge labelled ``cp``.
1057
+ std::uint32_t f = t.nodes[u].failure;
1058
+ while (true) {
1059
+ auto fit = t.nodes[f].children.find(cp);
1060
+ if (fit != t.nodes[f].children.end() && fit->second != v) {
1061
+ t.nodes[v].failure = fit->second;
1062
+ break;
1063
+ }
1064
+ if (f == 0) {
1065
+ t.nodes[v].failure = 0;
1066
+ break;
1067
+ }
1068
+ f = t.nodes[f].failure;
1069
+ }
1070
+ queue.push_back(v);
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ RuleTrie build_trie(std::vector<Rule>&& rules) {
1076
+ RuleTrie t;
1077
+ t.nodes.emplace_back(); // root
1078
+ for (auto& r : rules) {
1079
+ if (!r.pattern.empty()) trie_add_rule(t, std::move(r));
1080
+ }
1081
+ build_failures(t);
1082
+ return t;
1083
+ }
1084
+
1085
+ struct RuleHit {
1086
+ bool found;
1087
+ std::size_t pattern_length;
1088
+ const PhonemeExpr* expr;
1089
+ };
1090
+
1091
+ // Per-thread scratch for ``find_rule`` — the trie walk collects
1092
+ // candidate rule IDs grouped by depth so the longest-pattern winner
1093
+ // can be picked with the file-order tiebreak intact.
1094
+ struct FindScratch {
1095
+ static constexpr std::size_t kCap = 16; // > observed max pattern length
1096
+ std::array<std::vector<std::uint32_t>, kCap> by_depth;
1097
+ std::size_t depth_used = 0;
1098
+ void reset() {
1099
+ for (std::size_t i = 0; i < depth_used; ++i) by_depth[i].clear();
1100
+ depth_used = 0;
1101
+ }
1102
+ };
1103
+
1104
+ inline FindScratch& find_scratch() {
1105
+ thread_local FindScratch s;
1106
+ return s;
1107
+ }
1108
+
1109
+ RuleHit find_rule(const RuleTrie& trie,
1110
+ const CodepointVec& input,
1111
+ std::size_t pos) {
1112
+ if (trie.empty || pos >= input.size()) return {false, 0, nullptr};
1113
+
1114
+ auto& scratch = find_scratch();
1115
+ scratch.reset();
1116
+
1117
+ // Walk the trie from root, advancing through input[pos], input[pos+1],
1118
+ // ..., collecting rule IDs that end at each visited node by depth.
1119
+ std::uint32_t node = 0;
1120
+ std::size_t remaining = std::min(trie.max_pattern_length,
1121
+ input.size() - pos);
1122
+ if (remaining >= FindScratch::kCap) remaining = FindScratch::kCap - 1;
1123
+ for (std::size_t k = 0; k < remaining; ++k) {
1124
+ auto it = trie.nodes[node].children.find(input[pos + k]);
1125
+ if (it == trie.nodes[node].children.end()) break;
1126
+ node = it->second;
1127
+ const auto& rids = trie.nodes[node].rule_ids;
1128
+ if (!rids.empty()) {
1129
+ scratch.by_depth[k] = rids; // depth d=k+1, indexed at k
1130
+ if (k + 1 > scratch.depth_used) scratch.depth_used = k + 1;
1131
+ }
1132
+ }
1133
+
1134
+ // Iterate by descending depth (longest pattern first); within each
1135
+ // depth iterate in file order (the order the rules were inserted),
1136
+ // matching ``Rule.parseRules`` in the upstream port.
1137
+ for (std::size_t d = scratch.depth_used; d > 0; --d) {
1138
+ const auto& rids = scratch.by_depth[d - 1];
1139
+ for (const auto rid : rids) {
1140
+ const Rule& r = trie.rules[rid];
1141
+ if (!r.lcontext.match(input, pos)) continue;
1142
+ if (!r.rcontext.match(input, pos + d)) continue;
1143
+ return {true, d, &r.phoneme_expr};
1144
+ }
1145
+ }
1146
+ return {false, 0, nullptr};
1147
+ }
1148
+
1149
+ // ----- Per-thread arena ---------------------------------------------------
1150
+
1151
+ PhonemeArena& thread_arena() {
1152
+ thread_local PhonemeArena a;
1153
+ return a;
1154
+ }
1155
+
1156
+ // Live RuleTrie caches built lazily from BmpmTables. One trie per
1157
+ // (rule role × language) tuple; built once on first encode call.
1158
+ struct TrieCache {
1159
+ std::unordered_map<std::string, RuleTrie> main;
1160
+ std::unordered_map<std::string, RuleTrie> approx;
1161
+ std::unordered_map<std::string, RuleTrie> exact;
1162
+ std::once_flag once;
1163
+ };
1164
+
1165
+ TrieCache& tries_cache() {
1166
+ static TrieCache c;
1167
+ return c;
1168
+ }
1169
+
1170
+ void init_buckets_if_needed() {
1171
+ auto& c = tries_cache();
1172
+ std::call_once(c.once, []() {
1173
+ auto& tables = *g_tables;
1174
+ auto& cache = tries_cache();
1175
+ for (auto& [lang, rules] : tables.main_rules) {
1176
+ std::vector<Rule> copy = rules;
1177
+ cache.main.emplace(lang, build_trie(std::move(copy)));
1178
+ }
1179
+ for (auto& [lang, rules] : tables.approx_rules) {
1180
+ std::vector<Rule> copy = rules;
1181
+ cache.approx.emplace(lang, build_trie(std::move(copy)));
1182
+ }
1183
+ for (auto& [lang, rules] : tables.exact_rules) {
1184
+ std::vector<Rule> copy = rules;
1185
+ cache.exact.emplace(lang, build_trie(std::move(copy)));
1186
+ }
1187
+ });
1188
+ }
1189
+
1190
+ // ----- Prefix handling ----------------------------------------------------
1191
+
1192
+ // GENERIC prefixes from upstream PhoneticEngine.NAME_PREFIXES (GENERIC).
1193
+ // Stored as codepoint vectors so the engine can compare against the
1194
+ // codepoint input without going through bytes. All prefixes are ASCII
1195
+ // so widening each char to ``Codepoint`` is the identity.
1196
+ static const std::vector<CodepointVec>& generic_prefixes() {
1197
+ static const std::vector<CodepointVec> p = [] {
1198
+ static const char* kLiterals[] = {
1199
+ "da", "dal", "de", "del", "dela", "de la", "della",
1200
+ "des", "di", "do", "dos", "du", "van", "von",
1201
+ };
1202
+ std::vector<CodepointVec> out;
1203
+ out.reserve(sizeof(kLiterals) / sizeof(kLiterals[0]));
1204
+ for (const char* s : kLiterals) {
1205
+ CodepointVec v;
1206
+ for (; *s; ++s) v.push_back(static_cast<Codepoint>(
1207
+ static_cast<unsigned char>(*s)));
1208
+ out.push_back(std::move(v));
1209
+ }
1210
+ return out;
1211
+ }();
1212
+ return p;
1213
+ }
1214
+
1215
+ // ----- Encoding -----------------------------------------------------------
1216
+
1217
+ // Forward decl for d'/prefix recursion.
1218
+ std::string encode_impl(const CodepointVec& input,
1219
+ BmpmRuleType rule_type,
1220
+ bool concat,
1221
+ std::size_t max_phonemes,
1222
+ const LangSet& languages);
1223
+
1224
+ std::string encode_one_word(const CodepointVec& input,
1225
+ BmpmRuleType rule_type,
1226
+ std::size_t max_phonemes,
1227
+ const LangSet& languages) {
1228
+ auto& arena = thread_arena();
1229
+ arena.reset();
1230
+ arena.start_with_languages(languages);
1231
+
1232
+ // ``input`` is already ASCII-lowercased (the public ``beider_morse``
1233
+ // entry normalises before the prefix split). No UTF-8 decode, no
1234
+ // byte offset table — the engine runs on codepoints throughout and
1235
+ // the ``kRegex`` predicate fallback lazy-encodes its slice on
1236
+ // demand.
1237
+ const CodepointVec& cps = input;
1238
+
1239
+ const auto& cache = tries_cache();
1240
+ auto trie_for = [&](const std::string& lang) -> const RuleTrie* {
1241
+ auto it = cache.main.find(lang);
1242
+ if (it == cache.main.end()) return nullptr;
1243
+ return &it->second;
1244
+ };
1245
+
1246
+ // Pick the rule set: singleton language uses its own; otherwise "any".
1247
+ const RuleTrie* main_b = nullptr;
1248
+ if (languages.singleton()) {
1249
+ const int idx = languages.first_index();
1250
+ if (idx >= 0 && idx < static_cast<int>(g_tables->registry.names.size())) {
1251
+ main_b = trie_for(g_tables->registry.names[idx]);
1252
+ }
1253
+ }
1254
+ if (main_b == nullptr) main_b = trie_for("any");
1255
+ if (main_b == nullptr) return {};
1256
+
1257
+ std::size_t i = 0;
1258
+ while (i < cps.size()) {
1259
+ auto hit = find_rule(*main_b, cps, i);
1260
+ if (!hit.found) {
1261
+ ++i;
1262
+ continue;
1263
+ }
1264
+ arena.apply(*hit.expr, max_phonemes);
1265
+ i += hit.pattern_length;
1266
+ }
1267
+
1268
+ // Final rules: apply common then language-specific. Each final-rule
1269
+ // pass takes each live phoneme's codepoint text as the new input
1270
+ // and re-encodes it through the final-rule trie. Phonemes with empty
1271
+ // language sets drop. ``next_arena``, ``local``, and ``sub`` are
1272
+ // hoisted out of the per-phoneme loop; each iteration just clears
1273
+ // them back to empty, keeping their already-allocated capacity.
1274
+ PhonemeArena next_arena;
1275
+ PhonemeArena local;
1276
+ CodepointVec sub;
1277
+
1278
+ auto apply_final = [&](const std::string& lang_label) {
1279
+ const auto& bucket_map = (rule_type == BmpmRuleType::kApprox)
1280
+ ? cache.approx
1281
+ : cache.exact;
1282
+ auto it = bucket_map.find(lang_label);
1283
+ if (it == bucket_map.end()) return;
1284
+ const RuleTrie& b = it->second;
1285
+ if (b.empty) return;
1286
+
1287
+ next_arena.reset();
1288
+ for (const auto& ph : arena.phonemes) {
1289
+ sub.assign(arena.buffer.begin() + ph.offset,
1290
+ arena.buffer.begin() + ph.offset + ph.length);
1291
+
1292
+ local.reset();
1293
+ local.start_with_languages(ph.langs);
1294
+ std::size_t k = 0;
1295
+ CodepointVec one_buf;
1296
+ one_buf.reserve(1);
1297
+ while (k < sub.size()) {
1298
+ auto hit = find_rule(b, sub, k);
1299
+ if (hit.found) {
1300
+ local.apply(*hit.expr, max_phonemes);
1301
+ k += hit.pattern_length;
1302
+ } else {
1303
+ one_buf.assign(1, sub[k]);
1304
+ local.append_literal(one_buf);
1305
+ ++k;
1306
+ }
1307
+ }
1308
+ // Merge ``local.phonemes`` into ``next_arena.phonemes`` by text+langset.
1309
+ for (const auto& p : local.phonemes) {
1310
+ ArenaPhoneme moved;
1311
+ moved.langs = p.langs;
1312
+ moved.offset = static_cast<std::uint32_t>(next_arena.buffer.size());
1313
+ moved.length = p.length;
1314
+ next_arena.buffer.insert(next_arena.buffer.end(),
1315
+ local.buffer.begin() + p.offset,
1316
+ local.buffer.begin() + p.offset + p.length);
1317
+ next_arena.phonemes.push_back(moved);
1318
+ }
1319
+ }
1320
+ arena.buffer.swap(next_arena.buffer);
1321
+ arena.phonemes.swap(next_arena.phonemes);
1322
+ };
1323
+
1324
+ apply_final("common");
1325
+ if (languages.singleton()) {
1326
+ const int idx = languages.first_index();
1327
+ if (idx >= 0 && idx < static_cast<int>(g_tables->registry.names.size())) {
1328
+ apply_final(g_tables->registry.names[idx]);
1329
+ }
1330
+ } else {
1331
+ apply_final("any");
1332
+ }
1333
+
1334
+ return render_arena(arena);
1335
+ }
1336
+
1337
+ // Helper: does ``input[0..prefix.size()]`` equal ``prefix``?
1338
+ inline bool starts_with(const CodepointVec& input, const CodepointVec& prefix) {
1339
+ if (input.size() < prefix.size()) return false;
1340
+ for (std::size_t i = 0; i < prefix.size(); ++i) {
1341
+ if (input[i] != prefix[i]) return false;
1342
+ }
1343
+ return true;
1344
+ }
1345
+
1346
+ std::string encode_impl(const CodepointVec& input,
1347
+ BmpmRuleType rule_type,
1348
+ bool concat,
1349
+ std::size_t max_phonemes,
1350
+ const LangSet& languages) {
1351
+ // Generic ``d'`` prefix handling (the apostrophe is ASCII 0x27).
1352
+ if (input.size() >= 2 && input[0] == 'd' && input[1] == '\'') {
1353
+ CodepointVec remainder(input.begin() + 2, input.end());
1354
+ CodepointVec combined;
1355
+ combined.reserve(1 + remainder.size());
1356
+ combined.push_back('d');
1357
+ combined.insert(combined.end(), remainder.begin(), remainder.end());
1358
+ return "(" + encode_impl(remainder, rule_type, concat, max_phonemes,
1359
+ g_tables->lang.guess(remainder)) +
1360
+ ")-(" + encode_impl(combined, rule_type, concat, max_phonemes,
1361
+ g_tables->lang.guess(combined)) +
1362
+ ")";
1363
+ }
1364
+ for (const auto& prefix : generic_prefixes()) {
1365
+ if (input.size() < prefix.size() + 1) continue;
1366
+ if (!starts_with(input, prefix)) continue;
1367
+ if (input[prefix.size()] != ' ') continue;
1368
+ CodepointVec remainder(input.begin() + prefix.size() + 1, input.end());
1369
+ CodepointVec combined;
1370
+ combined.reserve(prefix.size() + remainder.size());
1371
+ combined.insert(combined.end(), prefix.begin(), prefix.end());
1372
+ combined.insert(combined.end(), remainder.begin(), remainder.end());
1373
+ return "(" + encode_impl(remainder, rule_type, concat, max_phonemes,
1374
+ g_tables->lang.guess(remainder)) +
1375
+ ")-(" + encode_impl(combined, rule_type, concat, max_phonemes,
1376
+ g_tables->lang.guess(combined)) +
1377
+ ")";
1378
+ }
1379
+
1380
+ // Split on space; GENERIC does not strip prefix words. Word ranges
1381
+ // are half-open ``[begin, end)`` indices into ``input``.
1382
+ std::vector<std::pair<std::size_t, std::size_t>> ranges;
1383
+ std::size_t s = 0;
1384
+ for (std::size_t k = 0; k <= input.size(); ++k) {
1385
+ if (k == input.size() || input[k] == ' ') {
1386
+ if (k > s) ranges.emplace_back(s, k);
1387
+ s = k + 1;
1388
+ }
1389
+ }
1390
+ if (ranges.empty()) return {};
1391
+
1392
+ if (concat) {
1393
+ CodepointVec joined;
1394
+ joined.reserve(input.size());
1395
+ for (std::size_t k = 0; k < ranges.size(); ++k) {
1396
+ if (k != 0) joined.push_back(' ');
1397
+ joined.insert(joined.end(),
1398
+ input.begin() + ranges[k].first,
1399
+ input.begin() + ranges[k].second);
1400
+ }
1401
+ return encode_one_word(joined, rule_type, max_phonemes, languages);
1402
+ }
1403
+
1404
+ if (ranges.size() == 1) {
1405
+ CodepointVec word(input.begin() + ranges[0].first,
1406
+ input.begin() + ranges[0].second);
1407
+ return encode_one_word(word, rule_type, max_phonemes, languages);
1408
+ }
1409
+ std::string out;
1410
+ for (std::size_t k = 0; k < ranges.size(); ++k) {
1411
+ if (k != 0) out.push_back('-');
1412
+ CodepointVec word(input.begin() + ranges[k].first,
1413
+ input.begin() + ranges[k].second);
1414
+ out.append(encode_one_word(word, rule_type, max_phonemes,
1415
+ g_tables->lang.guess(word)));
1416
+ }
1417
+ return out;
1418
+ }
1419
+
1420
+ } // namespace
1421
+
1422
+ // ----- Public API ---------------------------------------------------------
1423
+
1424
+ void bmpm_register_resources(
1425
+ const std::unordered_map<std::string, std::string>& resources) {
1426
+ {
1427
+ std::lock_guard<std::mutex> lock(g_pending_mutex);
1428
+ if (!g_pending_resources) {
1429
+ g_pending_resources =
1430
+ std::make_unique<std::unordered_map<std::string, std::string>>(
1431
+ resources);
1432
+ }
1433
+ }
1434
+ // Trigger the call_once so subsequent calls don't race the first
1435
+ // encode call.
1436
+ init_tables_if_needed();
1437
+ init_buckets_if_needed();
1438
+ }
1439
+
1440
+ std::string beider_morse(const std::vector<Codepoint>& input,
1441
+ BmpmRuleType rule_type,
1442
+ bool concat,
1443
+ std::size_t max_phonemes) {
1444
+ init_tables_if_needed();
1445
+ init_buckets_if_needed();
1446
+ if (input.empty()) return {};
1447
+
1448
+ // Normalise in codepoint space: ASCII-lowercase, ``-`` -> ``' '``,
1449
+ // trim leading/trailing spaces.
1450
+ CodepointVec norm = input;
1451
+ for (auto& cp : norm) {
1452
+ if (cp >= 'A' && cp <= 'Z') cp += 32;
1453
+ else if (cp == '-') cp = ' ';
1454
+ }
1455
+ std::size_t l = 0, r = norm.size();
1456
+ while (l < r && norm[l] == ' ') ++l;
1457
+ while (r > l && norm[r - 1] == ' ') --r;
1458
+ if (l != 0 || r != norm.size()) {
1459
+ norm = CodepointVec(norm.begin() + l, norm.begin() + r);
1460
+ }
1461
+ if (norm.empty()) return {};
1462
+
1463
+ const LangSet languages = g_tables->lang.guess(norm);
1464
+ return encode_impl(norm, rule_type, concat, max_phonemes, languages);
1465
+ }
1466
+
1467
+ } // namespace stride_align::phonetic