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,486 @@
1
+ #pragma once
2
+
3
+ // ``sa.partial_ratio`` — best Indel-normalised similarity over a
4
+ // substring-anchored alignment of the shorter input inside the longer.
5
+ //
6
+ // Two-phase search (replaces an earlier fuzzywuzzy-style matching-
7
+ // blocks heuristic that systematically under-scored vs rapidfuzz when
8
+ // the optimal alignment was a boundary-anchored variable-length window):
9
+ //
10
+ // 1. **Interior phase** — best similarity over fixed-length-``n``
11
+ // windows ``long[s : s+n]`` for each offset ``s ∈ [0, m-n]``,
12
+ // where ``n = |shorter|`` and ``m = |longer|``.
13
+ //
14
+ // 2. **Boundary phase** — best similarity over **variable-length**
15
+ // windows anchored at the long string's left edge
16
+ // (``long[0 : i]`` for ``i ∈ [1, n-1]``) and at its right edge
17
+ // (``long[m-i : m]`` for ``i ∈ [1, n-1]``). These capture the
18
+ // case where the shorter only partially overlaps the longer at
19
+ // its ends — the alignment shape that rapidfuzz reports via
20
+ // ``partial_ratio_alignment`` with ``dest_end - dest_start < n``.
21
+ //
22
+ // The reported score is ``max{1 - indel(shorter, window) / (n + |window|)}``
23
+ // over every window the two phases enumerate.
24
+ //
25
+ // **Cutoff pushdown.** Every per-window Indel call carries a distance
26
+ // cutoff derived from the running best score: any window whose indel
27
+ // would not strictly exceed the current best is rejected before the
28
+ // per-character loop finishes. The cutoff is converted per window
29
+ // because the denominator ``n + |window|`` varies across the boundary
30
+ // phase.
31
+ //
32
+ // **Boundary char-set skip.** When extending a boundary window by one
33
+ // character, if that newly-added character does not appear in the
34
+ // shorter's character set, the LCS cannot grow but the denominator
35
+ // does — so normalised similarity strictly decreases. We skip those
36
+ // extensions without calling the Indel kernel. Sound and meaningfully
37
+ // fast on random / large-alphabet workloads.
38
+ //
39
+ // **Equal-length swap.** When ``|a| == |b|``, "partial" is ambiguous —
40
+ // either input can play the role of the pattern. The engine runs the
41
+ // one-direction search twice (with arguments swapped) and returns the
42
+ // better score. This matches rapidfuzz's behaviour on equal-length
43
+ // inputs.
44
+ //
45
+ // Architectural inspiration from rapidfuzz-cpp (MIT, Max Bachmann):
46
+ // the two-phase interior + boundary structure, the per-window
47
+ // monotonically-tightened cutoff, and the boundary char-set skip
48
+ // were absorbed from their implementation via a delegated source-
49
+ // reading agent so the code-authoring path here stays clean-room.
50
+ // The triangle-inequality branch-and-bound over offsets that
51
+ // rapidfuzz layers on the interior phase is not (yet) implemented
52
+ // here — a linear offset scan is functionally equivalent (same
53
+ // answer, slower in best case).
54
+
55
+ #include <algorithm>
56
+ #include <array>
57
+ #include <cmath>
58
+ #include <cstddef>
59
+ #include <cstdint>
60
+ #include <span>
61
+ #include <type_traits>
62
+ #include <unordered_set>
63
+ #include <vector>
64
+
65
+ #include "stride_align/indel.hpp"
66
+ #include "stride_align/lcs.hpp"
67
+
68
+ namespace stride_align::partial_ratio {
69
+
70
+ using Codepoint = ::stride_align::lcs::Codepoint;
71
+
72
+ // Character-set membership over the shorter's alphabet, used for the
73
+ // boundary-extension skip. Specialised on Token: a 256-bit bitset
74
+ // for the byte fast path, ``std::unordered_set`` for the codepoint
75
+ // fallback.
76
+ template <typename Token>
77
+ class CharSet {
78
+ public:
79
+ void insert(Token c) {
80
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
81
+ const auto cc = static_cast<unsigned>(c);
82
+ bits_[cc >> 6] |= std::uint64_t{1} << (cc & 63U);
83
+ } else {
84
+ set_.insert(c);
85
+ }
86
+ }
87
+ bool contains(Token c) const {
88
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
89
+ const auto cc = static_cast<unsigned>(c);
90
+ return ((bits_[cc >> 6] >> (cc & 63U)) & 1U) != 0U;
91
+ } else {
92
+ return set_.find(c) != set_.end();
93
+ }
94
+ }
95
+
96
+ private:
97
+ std::array<std::uint64_t, 4> bits_{}; // only used for byte path
98
+ std::unordered_set<Token> set_{}; // only used for codepoint path
99
+ };
100
+
101
+ // Convert a normalised-similarity best ``[0, 1]`` and a window-size
102
+ // total ``total = n + |window|`` to an integer indel cutoff such that
103
+ // any indel ``> cutoff`` cannot beat the current best.
104
+ //
105
+ // sim > best iff indel < total * (1 - best)
106
+ //
107
+ // For integer indel that's ``indel <= ceil(total * (1 - best)) - 1``.
108
+ // The Indel kernel returns ``cutoff + 1`` for any indel ``> cutoff``,
109
+ // so passing this cutoff in tells the kernel to bail as soon as it
110
+ // can prove the answer won't improve ``best``.
111
+ inline std::size_t cutoff_for(double best, std::size_t total) {
112
+ if (best <= 0.0) return ::stride_align::indel::kNoCutoff;
113
+ const double max_d = static_cast<double>(total) * (1.0 - best);
114
+ if (max_d <= 0.0) return 0U;
115
+ const double ceil_d = std::ceil(max_d);
116
+ const auto ceil_int = static_cast<std::size_t>(ceil_d);
117
+ return ceil_int == 0U ? 0U : (ceil_int - 1U);
118
+ }
119
+
120
+ // One-direction search: pattern is ``short_s`` (n chars), text is
121
+ // ``long_s`` (m chars), with ``n <= m`` required. Returns the best
122
+ // normalised similarity in ``[0, 1]``. ``initial_best`` lets the
123
+ // caller seed the cutoff (used by the equal-length swap path so the
124
+ // second direction's cutoff starts where the first direction left
125
+ // off, not at zero).
126
+ template <typename Token>
127
+ inline double partial_ratio_one_direction(
128
+ std::span<const Token> short_s,
129
+ std::span<const Token> long_s,
130
+ double initial_best) {
131
+ const std::size_t n = short_s.size();
132
+ const std::size_t m = long_s.size();
133
+ if (n == 0U || m == 0U) return initial_best;
134
+ if (n > m) return initial_best; // caller invariant violated
135
+
136
+ // Build the per-pattern PEQ once. Every window call reuses it.
137
+ const auto prepared = ::stride_align::indel::prepare_indel_pattern<Token>(
138
+ short_s);
139
+
140
+ // Character set of the pattern — used by the boundary-extension
141
+ // skip below.
142
+ CharSet<Token> in_pattern;
143
+ for (auto c : short_s) in_pattern.insert(c);
144
+
145
+ double best = initial_best;
146
+
147
+ // For multi-word byte patterns the per-window indel call would
148
+ // otherwise rebuild the 256 * K * 8-byte PEQ on every call (50+
149
+ // boundary windows × ~1000 ns PEQ fill = 50+ µs of pure overhead).
150
+ // Build it once into a thread-local buffer, then have the per-
151
+ // window kernel skip the rebuild.
152
+ thread_local std::vector<std::uint64_t> mw_peq;
153
+ const std::size_t mw_K = (n + 63U) / 64U;
154
+ const bool use_mw_peq =
155
+ std::is_same_v<Token, std::uint8_t> && n > 64U;
156
+ if (use_mw_peq) {
157
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
158
+ ::stride_align::indel::build_multi_word_peq_u8(
159
+ std::span<const std::uint8_t>(
160
+ reinterpret_cast<const std::uint8_t*>(short_s.data()), n),
161
+ mw_K, mw_peq);
162
+ }
163
+ }
164
+
165
+ // Per-window call: builds the right cutoff, runs the Indel kernel
166
+ // against the prepared PEQ, and returns the **actual indel distance**
167
+ // (or ``cutoff + 1`` if the kernel bailed). The score update on
168
+ // ``best`` happens inline; callers care only about whether a perfect
169
+ // match was found and (for the interior phase) about the cached
170
+ // distance value used by the triangle-inequality pruning.
171
+ auto run_window = [&](std::size_t start, std::size_t w) -> std::size_t {
172
+ const std::size_t total = n + w;
173
+ const std::size_t cutoff = cutoff_for(best, total);
174
+ std::size_t d;
175
+ if (use_mw_peq) {
176
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
177
+ d = ::stride_align::indel::indel_distance_multi_word_u8_with_peq(
178
+ mw_peq.data(), mw_K, n,
179
+ std::span<const std::uint8_t>(
180
+ reinterpret_cast<const std::uint8_t*>(long_s.data() + start), w),
181
+ cutoff);
182
+ } else {
183
+ d = ::stride_align::indel::indel_distance_prepared<Token>(
184
+ prepared,
185
+ std::span<const Token>(long_s.data() + start, w),
186
+ cutoff);
187
+ }
188
+ } else {
189
+ d = ::stride_align::indel::indel_distance_prepared<Token>(
190
+ prepared,
191
+ std::span<const Token>(long_s.data() + start, w),
192
+ cutoff);
193
+ }
194
+ if (cutoff != ::stride_align::indel::kNoCutoff && d > cutoff) {
195
+ return d; // bailed; ``d == cutoff + 1`` is a sound upper-bound
196
+ }
197
+ const double sim = 1.0 - static_cast<double>(d) / static_cast<double>(total);
198
+ if (sim > best) best = sim;
199
+ return d;
200
+ };
201
+
202
+ auto try_window = [&](std::size_t start, std::size_t w) -> bool {
203
+ (void)run_window(start, w);
204
+ return best >= 1.0;
205
+ };
206
+
207
+ // --- Interior phase: fixed-length-n windows over offsets ``[0, m-n]``.
208
+ //
209
+ // Divide-and-conquer evaluation guided by the triangle-inequality
210
+ // lower bound: shifting the window by one offset can change the
211
+ // indel distance by at most 1, so for any two evaluated offsets
212
+ // ``a < b`` the minimum distance over the open interval ``(a, b)``
213
+ // is bounded below by ``(D(a) + D(b) - (b - a)) / 2``. If even
214
+ // that bound implies a score below the running ``best``, the entire
215
+ // interval is pruned without further kernel calls. Both endpoints
216
+ // and the midpoint are forced; the recursion handles the halves.
217
+ if (n <= m) {
218
+ const std::size_t max_offset = m - n;
219
+
220
+ // Thread-local scratch — the D-by-offset cache and the work stack
221
+ // would otherwise allocate per partial_ratio call. A generation
222
+ // counter marks unevaluated slots without an O(m-n) memset of D.
223
+ thread_local std::vector<std::size_t> D;
224
+ thread_local std::vector<std::uint32_t> D_gen;
225
+ thread_local std::uint32_t D_epoch = 0;
226
+ thread_local std::vector<std::pair<std::size_t, std::size_t>> work;
227
+ if (++D_epoch == 0U) {
228
+ // Wrap: force every slot to look unevaluated under the new epoch.
229
+ std::fill(D_gen.begin(), D_gen.end(), 0U);
230
+ D_epoch = 1U;
231
+ }
232
+ const std::size_t need = max_offset + 1U;
233
+ if (D.size() < need) {
234
+ D.resize(need);
235
+ D_gen.resize(need, 0U);
236
+ }
237
+ work.clear();
238
+
239
+ auto eval = [&](std::size_t s) -> std::size_t {
240
+ if (D_gen[s] == D_epoch) return D[s];
241
+ D[s] = run_window(s, n);
242
+ D_gen[s] = D_epoch;
243
+ return D[s];
244
+ };
245
+
246
+ // Evaluate both endpoints so the recursion has both ends pinned.
247
+ eval(0);
248
+ if (best >= 1.0) return 1.0;
249
+ if (max_offset > 0) {
250
+ eval(max_offset);
251
+ if (best >= 1.0) return 1.0;
252
+ }
253
+
254
+ // Lower bound on indel distance over the open interval ``(a, b)``.
255
+ // For fixed-length-n windows ``|D(s+1) - D(s)| <= 2``, so the
256
+ // tightest interval-wide bound is ``(D(a) + D(b)) / 2 - (b - a)``,
257
+ // clamped at 0.
258
+ auto interval_bound = [](std::size_t Da, std::size_t Db, std::size_t span) {
259
+ const std::size_t sum = Da + Db;
260
+ const std::size_t two_span = 2U * span;
261
+ return sum <= two_span ? std::size_t{0} : ((sum - two_span) / 2U);
262
+ };
263
+
264
+ if (max_offset >= 2) work.push_back({0, max_offset});
265
+ while (!work.empty()) {
266
+ const auto [a, b] = work.back();
267
+ work.pop_back();
268
+ if (b <= a + 1U) continue; // no interior offset
269
+
270
+ const std::size_t bound = interval_bound(D[a], D[b], b - a);
271
+ const std::size_t cutoff_d = cutoff_for(best, 2U * n);
272
+ if (cutoff_d != ::stride_align::indel::kNoCutoff && bound > cutoff_d) {
273
+ continue; // entire interval can't beat current best
274
+ }
275
+
276
+ const std::size_t mid = a + (b - a) / 2U;
277
+ eval(mid);
278
+ if (best >= 1.0) return 1.0;
279
+
280
+ work.push_back({mid, b});
281
+ work.push_back({a, mid});
282
+ }
283
+ }
284
+
285
+ // Length-based skip threshold for the boundary phases. A boundary
286
+ // window of length ``i`` against the pattern of length ``n`` has
287
+ // maximum possible normalised similarity ``2i / (n + i)`` — the
288
+ // best case where all ``i`` characters of the window contribute to
289
+ // LCS. Any ``i`` where that ceiling is at or below the current
290
+ // ``best`` cannot beat ``best``, so the kernel call is skipped
291
+ // entirely. Solving ``2i / (n + i) > best`` for ``i`` gives
292
+ // ``i > best * n / (2 - best)``. Floor + 1 is the smallest
293
+ // candidate length worth evaluating.
294
+ auto min_boundary_len = [&]() -> std::size_t {
295
+ if (best <= 0.0) return 1U;
296
+ if (best >= 1.0) return n; // skip everything below; we want >best
297
+ const double thresh = best * static_cast<double>(n) / (2.0 - best);
298
+ const std::size_t floored = static_cast<std::size_t>(thresh);
299
+ // We need ``i`` strictly greater than thresh; the next integer
300
+ // above thresh is the lowest candidate.
301
+ return floored + 1U;
302
+ };
303
+
304
+ // --- Prefix boundary phase: long[0:i] for i in [1, n-1].
305
+ //
306
+ // Boundary windows are nested: ``long[0:i+1] = long[0:i] +
307
+ // long[i]``. The bit-parallel Indel kernel's state vector ``V`` is
308
+ // a running summary of the LCS bit-vector after processing the
309
+ // text-so-far against the pattern, so a SINGLE incremental scan
310
+ // over ``long[0..n-1]`` covers every prefix window — one
311
+ // ``__builtin_ffs``-style update per text character instead of one
312
+ // full kernel call per window. O(n) total work instead of O(n^2).
313
+ //
314
+ // Only the single-word kernel (``n <= 64``) participates in this
315
+ // fast path; multi-word patterns fall back to the per-window
316
+ // ``try_window`` loop below. The single-word path is by far the
317
+ // common case for the user's sub-100-char workload.
318
+ if (n > 0U && n <= 64U) {
319
+ std::uint64_t V = prepared.mask;
320
+ std::size_t i_lo = min_boundary_len();
321
+ for (std::size_t k = 0; k < n - 1U; ++k) {
322
+ const Token c = long_s[k];
323
+ const std::uint64_t pm = prepared.peq_of(c);
324
+ if (pm == 0U) continue; // char not in pattern -> V unchanged, sim decreases
325
+ // Single-step Hyyrö update on V.
326
+ const std::uint64_t U = V & pm;
327
+ V = ((V + U) | (V - U)) & prepared.mask;
328
+
329
+ const std::size_t i = k + 1U;
330
+ if (i < i_lo) continue; // length-bound prunes this window
331
+
332
+ const std::size_t lcs =
333
+ n - static_cast<std::size_t>(std::popcount(V));
334
+ const std::size_t indel = n + i - 2U * lcs;
335
+ const std::size_t total = n + i;
336
+ const double sim =
337
+ 1.0 - static_cast<double>(indel) / static_cast<double>(total);
338
+ if (sim > best) {
339
+ best = sim;
340
+ if (best >= 1.0) return 1.0;
341
+ i_lo = min_boundary_len();
342
+ }
343
+ }
344
+ } else {
345
+ std::size_t i_lo = min_boundary_len();
346
+ for (std::size_t i = i_lo; i < n; ++i) {
347
+ if (!in_pattern.contains(long_s[i - 1])) continue;
348
+ if (try_window(0, i)) return 1.0;
349
+ const std::size_t new_lo = min_boundary_len();
350
+ if (new_lo > i + 1U) i = new_lo - 1U;
351
+ }
352
+ }
353
+
354
+ // --- Suffix boundary phase: long[m-i:m] for i in [1, n-1].
355
+ //
356
+ // Same incremental trick as the prefix phase, applied to the
357
+ // REVERSED text against the REVERSED pattern. LCS is symmetric
358
+ // under reversal, so ``indel(pattern, long[m-i:m])`` equals
359
+ // ``indel(reverse(pattern), reverse(long)[0:i])``. Build a PEQ over
360
+ // the reversed pattern once, then scan reversed long incrementally.
361
+ if (n > 0U && n <= 64U) {
362
+ // Build PEQ for the reversed pattern. The mask is the same.
363
+ std::array<std::uint64_t, 256> rev_peq{};
364
+ const std::uint64_t one = 1U;
365
+ for (std::size_t i = 0; i < n; ++i) {
366
+ // Reversed pattern: short_s[n - 1 - i] goes at position i.
367
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
368
+ rev_peq[short_s[n - 1U - i]] |= one << i;
369
+ } else {
370
+ // For codepoint tokens we don't have a 256-entry array; fall
371
+ // back to the per-window loop in that case.
372
+ }
373
+ }
374
+ if constexpr (std::is_same_v<Token, std::uint8_t>) {
375
+ std::uint64_t V = prepared.mask;
376
+ std::size_t i_lo = min_boundary_len();
377
+ for (std::size_t k = 0; k < n - 1U; ++k) {
378
+ // Read reversed long: long_s[m - 1 - k].
379
+ const Token c = long_s[m - 1U - k];
380
+ const std::uint64_t pm = rev_peq[c];
381
+ if (pm == 0U) continue;
382
+ const std::uint64_t U = V & pm;
383
+ V = ((V + U) | (V - U)) & prepared.mask;
384
+
385
+ const std::size_t i = k + 1U;
386
+ if (i < i_lo) continue;
387
+
388
+ const std::size_t lcs =
389
+ n - static_cast<std::size_t>(std::popcount(V));
390
+ const std::size_t indel = n + i - 2U * lcs;
391
+ const std::size_t total = n + i;
392
+ const double sim =
393
+ 1.0 - static_cast<double>(indel) / static_cast<double>(total);
394
+ if (sim > best) {
395
+ best = sim;
396
+ if (best >= 1.0) return 1.0;
397
+ i_lo = min_boundary_len();
398
+ }
399
+ }
400
+ return best;
401
+ }
402
+ }
403
+
404
+ // Multi-word / codepoint fallback for the suffix phase: per-window
405
+ // loop, unchanged from the original implementation.
406
+ // (Extending from length i-1 to length i adds long_s[m-i] at the
407
+ // LEFT of the window. Same skip applies.
408
+ {
409
+ std::size_t i_lo = min_boundary_len();
410
+ for (std::size_t i = i_lo; i < n; ++i) {
411
+ if (!in_pattern.contains(long_s[m - i])) continue;
412
+ if (try_window(m - i, i)) return 1.0;
413
+ const std::size_t new_lo = min_boundary_len();
414
+ if (new_lo > i + 1U) i = new_lo - 1U;
415
+ }
416
+ }
417
+
418
+ return best;
419
+ }
420
+
421
+ // Engine: handles the empty / one-empty / equal-length cases on top
422
+ // of the one-direction search. Takes spans so callers with external
423
+ // buffers (byte views, pre-owned vectors) avoid a defensive copy.
424
+ template <typename Token>
425
+ inline double partial_ratio_engine(
426
+ std::span<const Token> a,
427
+ std::span<const Token> b) {
428
+ if (a.empty() && b.empty()) return 1.0;
429
+ if (a.empty() || b.empty()) return 0.0;
430
+
431
+ const bool a_short = a.size() <= b.size();
432
+ const std::span<const Token> short_s = a_short ? a : b;
433
+ const std::span<const Token> long_s = a_short ? b : a;
434
+
435
+ double best = partial_ratio_one_direction<Token>(short_s, long_s, 0.0);
436
+
437
+ // Equal-length: "partial" is ambiguous — run the search with
438
+ // arguments swapped too and take the better of the two. The second
439
+ // call inherits the first call's best so its cutoff is tight from
440
+ // the start.
441
+ if (a.size() == b.size() && best < 1.0) {
442
+ best = partial_ratio_one_direction<Token>(long_s, short_s, best);
443
+ }
444
+ return best;
445
+ }
446
+
447
+ // Byte fast path entry: caller has already established that both
448
+ // inputs are byte-compatible (ASCII Python str, bytes-like, or
449
+ // pre-narrowed). No widening, per-codepoint check, or defensive
450
+ // copy — the engine is span-based.
451
+ inline double partial_ratio_bytes(
452
+ std::span<const std::uint8_t> a,
453
+ std::span<const std::uint8_t> b) {
454
+ return partial_ratio_engine<std::uint8_t>(a, b);
455
+ }
456
+
457
+ // Public entry: routes through the byte fast path when both inputs
458
+ // fit in the [0, 256) range (the common case for ASCII / Latin-1
459
+ // text), and through the codepoint path otherwise. The byte path
460
+ // uses the flat 256-row PEQ and the K-specialised multi-word Indel
461
+ // kernels; the codepoint path uses the hashmap PEQ.
462
+ inline double partial_ratio(
463
+ const std::vector<Codepoint>& a,
464
+ const std::vector<Codepoint>& b) {
465
+ if (a.empty() && b.empty()) return 1.0;
466
+ if (a.empty() || b.empty()) return 0.0;
467
+
468
+ bool fits_in_byte = true;
469
+ for (const auto cp : a) { if (cp >= 256U) { fits_in_byte = false; break; } }
470
+ if (fits_in_byte) {
471
+ for (const auto cp : b) { if (cp >= 256U) { fits_in_byte = false; break; } }
472
+ }
473
+
474
+ if (fits_in_byte) {
475
+ std::vector<std::uint8_t> ab(a.begin(), a.end());
476
+ std::vector<std::uint8_t> bb(b.begin(), b.end());
477
+ return partial_ratio_engine<std::uint8_t>(
478
+ std::span<const std::uint8_t>(ab),
479
+ std::span<const std::uint8_t>(bb));
480
+ }
481
+ return partial_ratio_engine<Codepoint>(
482
+ std::span<const Codepoint>(a),
483
+ std::span<const Codepoint>(b));
484
+ }
485
+
486
+ } // namespace stride_align::partial_ratio
@@ -0,0 +1,101 @@
1
+ #pragma once
2
+
3
+ // Ratcliff-Obershelp similarity (Ratcliff & Metzener, 1988) — the
4
+ // algorithm Python's ``difflib.SequenceMatcher.ratio()`` ships.
5
+ //
6
+ // Algorithm (recursive longest-matching-substring):
7
+ //
8
+ // 1. Find the longest common substring of ``a`` and ``b``.
9
+ // 2. Recurse on the unmatched prefixes ``a[:i] / b[:j]`` and on the
10
+ // unmatched suffixes ``a[i + k:] / b[j + k:]`` where the match
11
+ // starts at ``a[i]`` / ``b[j]`` and has length ``k``.
12
+ // 3. Total matching characters ``M`` is the sum of all match
13
+ // lengths found. Similarity is ``2 * M / (|a| + |b|)``.
14
+ //
15
+ // When ``a`` and ``b`` are both empty the similarity is 1.0 by
16
+ // convention (matches ``difflib.SequenceMatcher(None, "", "").ratio()``).
17
+ // When exactly one side is empty it is 0.0.
18
+ //
19
+ // The inner longest-common-substring step is the ``lcs_substring_info_range``
20
+ // DP from ``stride_align::lcs``; the recursion here adds the
21
+ // split-and-sum on top. Tiebreak on equal-length matches is "earliest
22
+ // in ``a``, then earliest in ``b``", matching the
23
+ // ``difflib.SequenceMatcher.find_longest_match`` contract.
24
+ //
25
+ // Source: textbook recursive Ratcliff-Obershelp. C++ here is original.
26
+
27
+ #include <array>
28
+ #include <cstddef>
29
+ #include <cstdint>
30
+ #include <vector>
31
+
32
+ #include "stride_align/lcs.hpp"
33
+
34
+ namespace stride_align::ratcliff_obershelp {
35
+
36
+ using Codepoint = std::uint32_t;
37
+
38
+ namespace ro_detail {
39
+
40
+ // Sum of all match lengths recovered by the recursive
41
+ // longest-matching-substring scan on the half-open ranges
42
+ // ``a[a_lo, a_hi)`` and ``b[b_lo, b_hi)``. Uses an explicit
43
+ // work-stack instead of recursion to keep the call depth bounded
44
+ // regardless of pathological inputs.
45
+ inline std::size_t sum_match_lengths(const std::vector<Codepoint>& a,
46
+ const std::vector<Codepoint>& b) {
47
+ std::size_t total = 0;
48
+ // Each work item is the rectangle we still need to scan.
49
+ std::vector<std::array<std::size_t, 4>> stack;
50
+ stack.reserve(8);
51
+ stack.push_back({0, a.size(), 0, b.size()});
52
+ while (!stack.empty()) {
53
+ const auto rect = stack.back();
54
+ stack.pop_back();
55
+ const std::size_t a_lo = rect[0];
56
+ const std::size_t a_hi = rect[1];
57
+ const std::size_t b_lo = rect[2];
58
+ const std::size_t b_hi = rect[3];
59
+ if (a_lo >= a_hi || b_lo >= b_hi) continue;
60
+
61
+ const auto info = ::stride_align::lcs::lcs_substring_info_range(
62
+ a, a_lo, a_hi, b, b_lo, b_hi);
63
+ if (info.length == 0) continue;
64
+
65
+ total += info.length;
66
+ const std::size_t a_match_start = info.end_a - info.length;
67
+ const std::size_t b_match_start = info.end_b - info.length;
68
+ // Push right half first, left half second; popping left next
69
+ // (LIFO) explores in left-to-right order — matches difflib's
70
+ // ``get_matching_blocks`` iteration order. Order does not affect
71
+ // the total but matches the upstream behaviour for any future
72
+ // matching-block exposure.
73
+ if (info.end_a < a_hi && info.end_b < b_hi) {
74
+ stack.push_back({info.end_a, a_hi, info.end_b, b_hi});
75
+ }
76
+ if (a_lo < a_match_start && b_lo < b_match_start) {
77
+ stack.push_back({a_lo, a_match_start, b_lo, b_match_start});
78
+ }
79
+ }
80
+ return total;
81
+ }
82
+
83
+ } // namespace ro_detail
84
+
85
+ // Ratcliff-Obershelp similarity in ``[0, 1]``: ``2 * M / (|a| + |b|)``
86
+ // where ``M`` is the total length of all matching blocks produced by
87
+ // the recursive longest-common-substring split. Bit-exact with
88
+ // ``difflib.SequenceMatcher(None, a, b).ratio()`` on equal-character
89
+ // inputs (the difflib autojunk heuristic does not apply when
90
+ // ``autojunk`` is the default off, which is the case for our codepoint
91
+ // inputs since there is no junk character set).
92
+ inline double ratcliff_obershelp_similarity(
93
+ const std::vector<Codepoint>& a,
94
+ const std::vector<Codepoint>& b) {
95
+ const std::size_t total_chars = a.size() + b.size();
96
+ if (total_chars == 0) return 1.0; // both empty -> identical
97
+ const std::size_t M = ro_detail::sum_match_lengths(a, b);
98
+ return 2.0 * static_cast<double>(M) / static_cast<double>(total_chars);
99
+ }
100
+
101
+ } // namespace stride_align::ratcliff_obershelp