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,363 @@
1
+ #pragma once
2
+
3
+ // Compatibility-style WRatio kernel.
4
+ //
5
+ // Reimplements rapidfuzz's documented WRatio recipe in C++ so the
6
+ // whole computation (ratio + len-ratio branch + token variants +
7
+ // partial variants) crosses the Python boundary exactly once instead
8
+ // of once per component. Closes the ~4× WRatio gap on x86/ARM that
9
+ // was driven entirely by Python wrapper overhead and intermediate
10
+ // ``float()`` / ``max()`` work in the previous Python recipe.
11
+ //
12
+ // Recipe (matches the compatibility reference bit-exactly):
13
+ //
14
+ // base = indel_normalized(a, b) # range [0, 1]
15
+ // len_ratio = max(|a|, |b|) / min(|a|, |b|)
16
+ // UNBASE_SCALE = 0.95
17
+ //
18
+ // if len_ratio < 1.5:
19
+ // # similar-length regime — token-based variants
20
+ // token_sort = token_sort_ratio(a, b) * UNBASE_SCALE
21
+ // token_set = token_set_ratio(a, b) * UNBASE_SCALE
22
+ // score = max(base, token_sort, token_set)
23
+ // else:
24
+ // # length-mismatched regime — partial variants dominate
25
+ // partial_scale = 0.6 if len_ratio > 8 else 0.9
26
+ // partial = partial_ratio(a, b) * partial_scale
27
+ // partial_token_sort = partial_token_sort_ratio(a, b) * UNBASE_SCALE * partial_scale
28
+ // partial_token_set = partial_token_set_ratio(a, b) * UNBASE_SCALE * partial_scale
29
+ // score = max(base, partial, partial_token_sort, partial_token_set)
30
+ //
31
+ // return score
32
+ //
33
+ // **Short-circuit logic.** Every non-base component is multiplied by
34
+ // a scale factor ``<= 0.95`` (in the similar-length branch) or
35
+ // ``<= 0.9 / 0.6`` (in the length-mismatched branch). The component
36
+ // value itself is in ``[0, 1]``, so the scaled value is bounded by
37
+ // the scale factor. Once ``base`` is already at least the maximum
38
+ // possible component score, no further computation can change the
39
+ // answer — return ``base`` and skip the (expensive) token / partial
40
+ // passes. This is the dominant fast path for high-quality matches.
41
+ //
42
+ // All component subroutines (token_sort, token_set, partial,
43
+ // partial_token_sort, partial_token_set) are inlined from
44
+ // ``token_ratios.hpp`` and ``partial_ratio.hpp`` so the whole
45
+ // recipe is a single function instantiation per Token type. The native
46
+ // stride-align contract is implemented separately at the end of this file.
47
+
48
+ #include <algorithm>
49
+ #include <cstddef>
50
+ #include <cstdint>
51
+ #include <span>
52
+ #include <type_traits>
53
+ #include <vector>
54
+
55
+ #include "stride_align/indel.hpp"
56
+ #include "stride_align/lcs.hpp"
57
+ #include "stride_align/partial_ratio.hpp"
58
+ #include "stride_align/token_ratios.hpp"
59
+
60
+ namespace stride_align::wratio {
61
+
62
+ using Codepoint = ::stride_align::lcs::Codepoint;
63
+
64
+ // Combined ``partial_token_sort_ratio`` on already-tokenised inputs.
65
+ // Sorts the tokens, joins, hands off to ``partial_ratio``.
66
+ template <typename Token>
67
+ inline double partial_token_sort_ratio_engine(
68
+ std::span<const Token> a,
69
+ std::span<const Token> b) {
70
+ auto ta = ::stride_align::token_ratios::tokens_of(a);
71
+ auto tb = ::stride_align::token_ratios::tokens_of(b);
72
+ std::sort(ta.begin(), ta.end(),
73
+ ::stride_align::token_ratios::LexLess<Token>{});
74
+ std::sort(tb.begin(), tb.end(),
75
+ ::stride_align::token_ratios::LexLess<Token>{});
76
+ std::vector<Token> ja, jb;
77
+ ::stride_align::token_ratios::join_with_space(ta, ja);
78
+ ::stride_align::token_ratios::join_with_space(tb, jb);
79
+ if (ja.empty() && jb.empty()) return 1.0;
80
+ if (ja.empty() || jb.empty()) return 0.0;
81
+
82
+ // Byte inputs go straight through partial_ratio_bytes (the fast path
83
+ // fuzz.partial_ratio already uses) — no uint8->codepoint widening, and
84
+ // the byte partial_ratio kernel rather than the codepoint one.
85
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
86
+ return ::stride_align::partial_ratio::partial_ratio_bytes(
87
+ std::span<const std::uint8_t>(ja), std::span<const std::uint8_t>(jb));
88
+ } else {
89
+ std::vector<Codepoint> ja_cp(ja.begin(), ja.end());
90
+ std::vector<Codepoint> jb_cp(jb.begin(), jb.end());
91
+ return ::stride_align::partial_ratio::partial_ratio(ja_cp, jb_cp);
92
+ }
93
+ }
94
+
95
+ // Combined ``partial_token_set_ratio``. Builds the three set
96
+ // candidates exactly as ``token_set_ratio_engine`` does, then runs
97
+ // ``partial_ratio`` over each pair and returns the max.
98
+ template <typename Token>
99
+ inline double partial_token_set_ratio_engine(
100
+ std::span<const Token> a,
101
+ std::span<const Token> b) {
102
+ using namespace ::stride_align::token_ratios;
103
+ auto ta = tokens_of(a);
104
+ auto tb = tokens_of(b);
105
+ if (ta.empty() || tb.empty()) return 0.0;
106
+
107
+ const LexLess<Token> lt;
108
+ std::sort(ta.begin(), ta.end(), lt);
109
+ ta.erase(std::unique(ta.begin(), ta.end(), LexEqual<Token>{}), ta.end());
110
+ std::sort(tb.begin(), tb.end(), lt);
111
+ tb.erase(std::unique(tb.begin(), tb.end(), LexEqual<Token>{}), tb.end());
112
+
113
+ std::vector<std::span<const Token>> intersect, diff_a, diff_b;
114
+ std::size_t i = 0, j = 0;
115
+ while (i < ta.size() && j < tb.size()) {
116
+ if (lt(ta[i], tb[j])) { diff_a.push_back(ta[i]); ++i; }
117
+ else if (lt(tb[j], ta[i])) { diff_b.push_back(tb[j]); ++j; }
118
+ else { intersect.push_back(ta[i]); ++i; ++j; }
119
+ }
120
+ while (i < ta.size()) diff_a.push_back(ta[i++]);
121
+ while (j < tb.size()) diff_b.push_back(tb[j++]);
122
+
123
+ if (diff_a.empty() && diff_b.empty()) return 1.0;
124
+
125
+ std::vector<std::span<const Token>> t1_parts(intersect);
126
+ t1_parts.insert(t1_parts.end(), diff_a.begin(), diff_a.end());
127
+ std::vector<std::span<const Token>> t2_parts(intersect);
128
+ t2_parts.insert(t2_parts.end(), diff_b.begin(), diff_b.end());
129
+
130
+ std::vector<Token> t0, t1, t2;
131
+ join_with_space(intersect, t0);
132
+ join_with_space(t1_parts, t1);
133
+ join_with_space(t2_parts, t2);
134
+
135
+ // Run partial_ratio on each pair (codepoint widening per pair).
136
+ auto pr = [](const std::vector<Token>& x, const std::vector<Token>& y) -> double {
137
+ if (x.empty() && y.empty()) return 1.0;
138
+ if (x.empty() || y.empty()) return 0.0;
139
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
140
+ return ::stride_align::partial_ratio::partial_ratio_bytes(
141
+ std::span<const std::uint8_t>(x), std::span<const std::uint8_t>(y));
142
+ } else {
143
+ std::vector<Codepoint> xc(x.begin(), x.end());
144
+ std::vector<Codepoint> yc(y.begin(), y.end());
145
+ return ::stride_align::partial_ratio::partial_ratio(xc, yc);
146
+ }
147
+ };
148
+ const double r0 = pr(t0, t1);
149
+ if (r0 >= 1.0) return 1.0;
150
+ const double r1 = pr(t0, t2);
151
+ if (r1 >= 1.0) return 1.0;
152
+ const double r2 = pr(t1, t2);
153
+ return std::max({r0, r1, r2});
154
+ }
155
+
156
+ // WRatio engine. Returns the WRatio score in ``[0, 1]`` (caller
157
+ // multiplies by 100 for the rapidfuzz convention).
158
+ //
159
+ // ``score_cutoff`` is interpreted as a *normalised* cutoff in
160
+ // ``[0, 1]`` — values at or below this threshold are returned as 0.
161
+ // This mirrors rapidfuzz's documented behaviour on WRatio.
162
+ template <typename Token>
163
+ inline double wratio_engine(
164
+ std::span<const Token> a,
165
+ std::span<const Token> b,
166
+ double score_cutoff /* normalised, [0, 1]; 0 == no cutoff */) {
167
+ if (a.empty() || b.empty()) return 0.0;
168
+
169
+ constexpr double UNBASE_SCALE = 0.95;
170
+
171
+ const double base = ::stride_align::token_ratios::indel_normalized<Token>(a, b);
172
+ const double len_a = static_cast<double>(a.size());
173
+ const double len_b = static_cast<double>(b.size());
174
+ const double len_ratio = (len_a > len_b ? len_a / len_b : len_b / len_a);
175
+
176
+ double best = base;
177
+
178
+ if (len_ratio < 1.5) {
179
+ // Similar-length regime. Token variants scale by 0.95, so once
180
+ // ``base >= 0.95`` no scaled token component can exceed it.
181
+ if (best >= UNBASE_SCALE) {
182
+ return best > score_cutoff ? best : 0.0;
183
+ }
184
+ // token_sort and token_set are both needed here. Use the combined
185
+ // engine that tokenises a/b once, sorts once, and shares the
186
+ // thread-local scratch buffers across the 1 + 3 indel calls.
187
+ double ts = 0.0, tx = 0.0;
188
+ ::stride_align::token_ratios::compute_token_sort_and_set_ratio<Token>(
189
+ a, b, ts, tx);
190
+ const double ts_scaled = ts * UNBASE_SCALE;
191
+ if (ts_scaled > best) best = ts_scaled;
192
+ const double tx_scaled = tx * UNBASE_SCALE;
193
+ if (tx_scaled > best) best = tx_scaled;
194
+ return best > score_cutoff ? best : 0.0;
195
+ }
196
+
197
+ // Length-mismatched regime. The dominant variant is ``partial`` (no
198
+ // unbase penalty), so its scaling factor caps the achievable score.
199
+ // rapidfuzz/fuzzywuzzy: ``partial_scale = 0.6 if len_ratio > 8 else 0.9`` —
200
+ // len_ratio == 8 stays in the 0.9 bucket. (Was ``< 8.0``, which put the
201
+ // exact-8 boundary in the 0.6 bucket and diverged from rapidfuzz, ~30 pts.)
202
+ const double partial_scale = (len_ratio > 8.0) ? 0.6 : 0.9;
203
+ // partial_token_* are scaled by UNBASE_SCALE * partial_scale, which
204
+ // is strictly less than ``partial_scale``. So ``partial_scale`` is
205
+ // the true ceiling for any non-base component here.
206
+ if (best >= partial_scale) {
207
+ return best > score_cutoff ? best : 0.0;
208
+ }
209
+
210
+ // Byte tokens go straight through the byte partial_ratio path
211
+ // (no uint8→codepoint widen / re-narrow). Wider tokens still
212
+ // widen once into codepoints for the public entry.
213
+ double pr;
214
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
215
+ pr = ::stride_align::partial_ratio::partial_ratio_bytes(a, b);
216
+ } else {
217
+ std::vector<Codepoint> a_cp(a.begin(), a.end());
218
+ std::vector<Codepoint> b_cp(b.begin(), b.end());
219
+ pr = ::stride_align::partial_ratio::partial_ratio(a_cp, b_cp);
220
+ }
221
+ const double pr_scaled = pr * partial_scale;
222
+ if (pr_scaled > best) best = pr_scaled;
223
+ // After ``partial`` the ceiling drops to ``UNBASE_SCALE *
224
+ // partial_scale`` because the remaining variants pay both penalties.
225
+ const double remaining_ceiling = UNBASE_SCALE * partial_scale;
226
+ if (best >= remaining_ceiling) {
227
+ return best > score_cutoff ? best : 0.0;
228
+ }
229
+
230
+ const double pts = partial_token_sort_ratio_engine<Token>(a, b);
231
+ const double pts_scaled = pts * remaining_ceiling;
232
+ if (pts_scaled > best) best = pts_scaled;
233
+ if (best >= remaining_ceiling) {
234
+ return best > score_cutoff ? best : 0.0;
235
+ }
236
+
237
+ const double pset = partial_token_set_ratio_engine<Token>(a, b);
238
+ const double pset_scaled = pset * remaining_ceiling;
239
+ if (pset_scaled > best) best = pset_scaled;
240
+ return best > score_cutoff ? best : 0.0;
241
+ }
242
+
243
+ // Byte fast path: caller has already established byte-compatible
244
+ // inputs.
245
+ inline double wratio_bytes(
246
+ std::span<const std::uint8_t> a,
247
+ std::span<const std::uint8_t> b,
248
+ double score_cutoff = 0.0) {
249
+ return wratio_engine<std::uint8_t>(a, b, score_cutoff);
250
+ }
251
+ inline double partial_token_sort_ratio_bytes(
252
+ std::span<const std::uint8_t> a,
253
+ std::span<const std::uint8_t> b) {
254
+ return partial_token_sort_ratio_engine<std::uint8_t>(a, b);
255
+ }
256
+ inline double partial_token_set_ratio_bytes(
257
+ std::span<const std::uint8_t> a,
258
+ std::span<const std::uint8_t> b) {
259
+ return partial_token_set_ratio_engine<std::uint8_t>(a, b);
260
+ }
261
+
262
+ // Public byte-fast-path-or-codepoint dispatcher.
263
+ inline double wratio(
264
+ const std::vector<Codepoint>& a,
265
+ const std::vector<Codepoint>& b,
266
+ double score_cutoff = 0.0) {
267
+ if (a.empty() || b.empty()) return 0.0;
268
+ bool fits_in_byte = true;
269
+ for (const auto cp : a) { if (cp >= 256U) { fits_in_byte = false; break; } }
270
+ if (fits_in_byte) {
271
+ for (const auto cp : b) { if (cp >= 256U) { fits_in_byte = false; break; } }
272
+ }
273
+ if (fits_in_byte) {
274
+ std::vector<std::uint8_t> ab(a.begin(), a.end());
275
+ std::vector<std::uint8_t> bb(b.begin(), b.end());
276
+ return wratio_engine<std::uint8_t>(
277
+ std::span<const std::uint8_t>(ab),
278
+ std::span<const std::uint8_t>(bb),
279
+ score_cutoff);
280
+ }
281
+ return wratio_engine<Codepoint>(
282
+ std::span<const Codepoint>(a),
283
+ std::span<const Codepoint>(b),
284
+ score_cutoff);
285
+ }
286
+
287
+ // stride-align's top-level WRatio contract predates the compatibility shim:
288
+ // it evaluates partial and token candidates in both length regimes. Keep the
289
+ // optimized compatibility recipe above available to the shim, while native
290
+ // language adapters call this complete recipe.
291
+ template <typename Token>
292
+ inline double native_wratio_engine(
293
+ std::span<const Token> a,
294
+ std::span<const Token> b) {
295
+ if (a.empty() || b.empty()) return a.empty() && b.empty() ? 1.0 : 0.0;
296
+
297
+ constexpr double kUnbaseScale = 0.95;
298
+ double partial_scale = 0.9;
299
+ double unbase_scale = kUnbaseScale;
300
+ const double len_a = static_cast<double>(a.size());
301
+ const double len_b = static_cast<double>(b.size());
302
+ const double len_ratio = len_a > len_b ? len_a / len_b : len_b / len_a;
303
+ if (len_ratio < 1.5) {
304
+ partial_scale = kUnbaseScale;
305
+ } else if (len_ratio >= 8.0) {
306
+ unbase_scale = 0.6;
307
+ }
308
+
309
+ double best = ::stride_align::token_ratios::indel_normalized<Token>(a, b);
310
+ if (best >= 1.0) return best;
311
+
312
+ double partial;
313
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
314
+ partial = ::stride_align::partial_ratio::partial_ratio_bytes(a, b);
315
+ } else {
316
+ std::vector<Codepoint> a_cp(a.begin(), a.end());
317
+ std::vector<Codepoint> b_cp(b.begin(), b.end());
318
+ partial = ::stride_align::partial_ratio::partial_ratio(a_cp, b_cp);
319
+ }
320
+ best = std::max(best, partial * partial_scale);
321
+
322
+ double token_sort = 0.0;
323
+ double token_set = 0.0;
324
+ ::stride_align::token_ratios::compute_token_sort_and_set_ratio<Token>(
325
+ a, b, token_sort, token_set);
326
+ best = std::max(best, token_sort * unbase_scale);
327
+ best = std::max(best, token_set * unbase_scale);
328
+
329
+ const double combined_scale = partial_scale * unbase_scale;
330
+ best = std::max(
331
+ best, partial_token_sort_ratio_engine<Token>(a, b) * combined_scale);
332
+ best = std::max(
333
+ best, partial_token_set_ratio_engine<Token>(a, b) * combined_scale);
334
+ return best;
335
+ }
336
+
337
+ inline double native_wratio(
338
+ const std::vector<Codepoint>& a,
339
+ const std::vector<Codepoint>& b) {
340
+ bool fits_in_byte = true;
341
+ for (const auto codepoint : a) {
342
+ if (codepoint >= 256U) {
343
+ fits_in_byte = false;
344
+ break;
345
+ }
346
+ }
347
+ if (fits_in_byte) {
348
+ for (const auto codepoint : b) {
349
+ if (codepoint >= 256U) {
350
+ fits_in_byte = false;
351
+ break;
352
+ }
353
+ }
354
+ }
355
+ if (fits_in_byte) {
356
+ const std::vector<std::uint8_t> left(a.begin(), a.end());
357
+ const std::vector<std::uint8_t> right(b.begin(), b.end());
358
+ return native_wratio_engine<std::uint8_t>(left, right);
359
+ }
360
+ return native_wratio_engine<Codepoint>(a, b);
361
+ }
362
+
363
+ } // namespace stride_align::wratio
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrideAlign
4
+ module MetaphoneVariant
5
+ PHILIPS = 0
6
+ JELLYFISH = 1
7
+ end
8
+
9
+ module DoubleMetaphoneVariant
10
+ COMMONS = 0
11
+ PYTHON = 1
12
+ end
13
+
14
+ module BmpmRuleType
15
+ APPROX = 0
16
+ EXACT = 1
17
+ end
18
+
19
+ class << self
20
+ def lcs_length(query, target)
21
+ pairwise(query, target) { |left, right| __primary_backend.lcs_length(left, right) }
22
+ end
23
+
24
+ def lcs_substring_length(query, target)
25
+ pairwise(query, target) do |left, right|
26
+ __primary_backend.lcs_substring_length(left, right)
27
+ end
28
+ end
29
+
30
+ def lcs_substring(query, target)
31
+ pairwise(query, target) { |left, right| __primary_backend.lcs_substring(left, right) }
32
+ end
33
+
34
+ def jaccard(query, target, n: 2)
35
+ pairwise(query, target) { |left, right| __primary_backend.jaccard(left, right, n) }
36
+ end
37
+
38
+ def dice(query, target, n: 2)
39
+ pairwise(query, target) { |left, right| __primary_backend.dice(left, right, n) }
40
+ end
41
+
42
+ def cosine(query, target, n: 2)
43
+ pairwise(query, target) { |left, right| __primary_backend.cosine(left, right, n) }
44
+ end
45
+
46
+ def overlap(query, target, n: 2)
47
+ pairwise(query, target) { |left, right| __primary_backend.overlap(left, right, n) }
48
+ end
49
+
50
+ def jaccard_similarities(query, targets, n: 2)
51
+ one_to_many(query, targets) { |left, right| jaccard(left, right, n: n) }
52
+ end
53
+
54
+ def dice_similarities(query, targets, n: 2)
55
+ one_to_many(query, targets) { |left, right| dice(left, right, n: n) }
56
+ end
57
+
58
+ def cosine_similarities(query, targets, n: 2)
59
+ one_to_many(query, targets) { |left, right| cosine(left, right, n: n) }
60
+ end
61
+
62
+ def overlap_similarities(query, targets, n: 2)
63
+ one_to_many(query, targets) { |left, right| overlap(left, right, n: n) }
64
+ end
65
+
66
+ def ratcliff_obershelp_similarity(query, target)
67
+ pairwise(query, target) do |left, right|
68
+ __primary_backend.ratcliff_obershelp(left, right)
69
+ end
70
+ end
71
+
72
+ def ratcliff_obershelp_similarities(query, targets)
73
+ one_to_many(query, targets) do |left, right|
74
+ ratcliff_obershelp_similarity(left, right)
75
+ end
76
+ end
77
+
78
+ def partial_ratio(query, target, processor: nil)
79
+ processed_pairwise(query, target, processor) do |left, right|
80
+ __primary_backend.partial_ratio(left, right)
81
+ end
82
+ end
83
+
84
+ def token_sort_ratio(query, target, processor: nil)
85
+ processed_pairwise(query, target, processor) do |left, right|
86
+ __primary_backend.token_sort_ratio(left, right)
87
+ end
88
+ end
89
+
90
+ def token_set_ratio(query, target, processor: nil)
91
+ processed_pairwise(query, target, processor) do |left, right|
92
+ __primary_backend.token_set_ratio(left, right)
93
+ end
94
+ end
95
+
96
+ def partial_token_sort_ratio(query, target, processor: nil)
97
+ processed_pairwise(query, target, processor) do |left, right|
98
+ __primary_backend.partial_token_sort_ratio(left, right)
99
+ end
100
+ end
101
+
102
+ def partial_token_set_ratio(query, target, processor: nil)
103
+ processed_pairwise(query, target, processor) do |left, right|
104
+ __primary_backend.partial_token_set_ratio(left, right)
105
+ end
106
+ end
107
+
108
+ def WRatio(query, target, processor: nil)
109
+ processed_pairwise(query, target, processor) do |left, right|
110
+ if left.nil? || right.nil?
111
+ nil
112
+ elsif left.empty? && right.empty?
113
+ 1.0
114
+ else
115
+ __primary_backend.wratio(left, right)
116
+ end
117
+ end
118
+ end
119
+
120
+ def monge_elkan(query, target, inner: :jaro, processor: nil,
121
+ symmetric: false)
122
+ query = scalar_query(query)
123
+ target = scalar_query(target)
124
+ raise ArgumentError, "query and target cannot be nil" if query.nil? || target.nil?
125
+ query = apply_processor(query, processor)
126
+ target = apply_processor(target, processor)
127
+ scorer = resolve_inner_scorer(inner)
128
+ left = query.split
129
+ right = target.split
130
+ return 1.0 if left.empty? && right.empty?
131
+ return 0.0 if left.empty? || right.empty?
132
+
133
+ forward = monge_direction(left, right, scorer)
134
+ return forward unless symmetric
135
+ (forward + monge_direction(right, left, scorer)) / 2.0
136
+ end
137
+
138
+ def soundex(input)
139
+ unary(input) { |value| __primary_backend.soundex(value) }
140
+ end
141
+
142
+ def soundex_equal(query, target)
143
+ phonetic_equal(query, target, :soundex, MetaphoneVariant::PHILIPS)
144
+ end
145
+
146
+ def metaphone(input, variant: MetaphoneVariant::PHILIPS)
147
+ unary(input) { |value| __primary_backend.metaphone(value, variant) }
148
+ end
149
+
150
+ def metaphone_equal(query, target, variant: MetaphoneVariant::PHILIPS)
151
+ phonetic_equal(query, target, :metaphone, variant)
152
+ end
153
+
154
+ def nysiis(input)
155
+ unary(input) { |value| __primary_backend.nysiis(value) }
156
+ end
157
+
158
+ def nysiis_equal(query, target)
159
+ phonetic_equal(query, target, :nysiis, MetaphoneVariant::PHILIPS)
160
+ end
161
+
162
+ def match_rating_codex(input)
163
+ unary(input) { |value| __primary_backend.match_rating_codex(value) }
164
+ end
165
+
166
+ def match_rating_compare(query, target)
167
+ pairwise(query, target) do |left, right|
168
+ __primary_backend.match_rating_compare(left, right)
169
+ end
170
+ end
171
+
172
+ def caverphone(input)
173
+ unary(input) { |value| __primary_backend.caverphone(value) }
174
+ end
175
+
176
+ def cologne_phonetic(input)
177
+ unary(input) { |value| __primary_backend.cologne_phonetic(value) }
178
+ end
179
+
180
+ def daitch_mokotoff(input, branching: true, folding: true)
181
+ unary(input) do |value|
182
+ __primary_backend.daitch_mokotoff(value, branching, folding)
183
+ end
184
+ end
185
+
186
+ def double_metaphone(input, max_length: 64,
187
+ variant: DoubleMetaphoneVariant::COMMONS)
188
+ unary(input) do |value|
189
+ result = __primary_backend.double_metaphone(value, max_length, variant)
190
+ result.nil? ? nil : { primary: result[0], alternate: result[1] }
191
+ end
192
+ end
193
+
194
+ def beider_morse(input, rule_type: BmpmRuleType::APPROX,
195
+ concat: true, max_phonemes: 20)
196
+ register_bmpm_resources
197
+ unary(input) do |value|
198
+ __primary_backend.beider_morse(value, rule_type, concat, max_phonemes)
199
+ end
200
+ end
201
+
202
+ def dtw(query, target, window: nil, distance: nil, score_cutoff: nil)
203
+ unless query.is_a?(Array) && target.is_a?(Array)
204
+ raise ArgumentError, "query and target must be numeric Arrays"
205
+ end
206
+ distance ||= query.all? { |value| value.is_a?(Integer) } ? "l1" : "l2_squared"
207
+ __primary_backend.dtw(query, target, window, distance.to_s, score_cutoff)
208
+ end
209
+
210
+ def dtw_distances(query, targets, window: nil, distance: nil,
211
+ score_cutoff: nil)
212
+ raise ArgumentError, "targets must be an Array of numeric Arrays" unless targets.is_a?(Array)
213
+ targets.map do |target|
214
+ dtw(
215
+ query, target, window: window, distance: distance,
216
+ score_cutoff: score_cutoff
217
+ )
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ def apply_processor(value, processor)
224
+ return value if processor.nil? || value.nil?
225
+ raise ArgumentError, "processor must respond to call or be nil" unless processor.respond_to?(:call)
226
+ result = processor.call(value)
227
+ raise ArgumentError, "processor must return one non-nil String" unless result.is_a?(String)
228
+ result.encode(Encoding::UTF_8)
229
+ end
230
+
231
+ def processed_pairwise(query, target, processor)
232
+ pairwise(query, target) do |left, right|
233
+ yield(apply_processor(left, processor), apply_processor(right, processor))
234
+ end
235
+ end
236
+
237
+ def resolve_inner_scorer(inner)
238
+ return inner if inner.respond_to?(:call)
239
+ case inner.to_s
240
+ when "jaro" then method(:jaro_similarity)
241
+ when "jaro_winkler" then method(:jaro_winkler_similarity)
242
+ when "levenshtein_ratio" then method(:levenshtein_normalized_score)
243
+ when "indel_ratio" then method(:indel_normalized_score)
244
+ else
245
+ raise ArgumentError, "unknown inner similarity: #{inner}"
246
+ end
247
+ end
248
+
249
+ def monge_direction(left, right, scorer)
250
+ left.sum do |token|
251
+ right.map do |candidate|
252
+ value = scorer.call(token, candidate)
253
+ unless value.is_a?(Numeric) && value.finite?
254
+ raise ArgumentError, "inner must return one finite numeric score"
255
+ end
256
+ value.to_f
257
+ end.max
258
+ end / left.length.to_f
259
+ end
260
+
261
+ def phonetic_equal(query, target, algorithm, variant)
262
+ pairwise(query, target) do |left, right|
263
+ if left.nil? || right.nil?
264
+ nil
265
+ else
266
+ left_code, right_code, equal = case algorithm
267
+ when :soundex
268
+ [__primary_backend.soundex(left),
269
+ __primary_backend.soundex(right),
270
+ __primary_backend.soundex_equal(left, right)]
271
+ when :metaphone
272
+ [__primary_backend.metaphone(left, variant),
273
+ __primary_backend.metaphone(right, variant),
274
+ __primary_backend.metaphone_equal(left, right, variant)]
275
+ else
276
+ [__primary_backend.nysiis(left),
277
+ __primary_backend.nysiis(right),
278
+ __primary_backend.nysiis_equal(left, right)]
279
+ end
280
+ !left_code.empty? && !right_code.empty? && equal
281
+ end
282
+ end
283
+ end
284
+
285
+ def register_bmpm_resources
286
+ return if @bmpm_registered
287
+ directory = __data_directory("bmpm_data")
288
+ resources = Dir[File.join(directory, "*.txt")].each_with_object({}) do |path, output|
289
+ output[File.basename(path, ".txt")] = File.binread(path)
290
+ end
291
+ raise LoadError, "stride-align BMPM rule resources are missing" if resources.empty?
292
+ __primary_backend.bmpm_register(resources)
293
+ @bmpm_registered = true
294
+ end
295
+ end
296
+ end