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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/NOTICE +255 -0
- data/README.md +107 -0
- data/data/bmpm_data/gen_approx_any.txt +131 -0
- data/data/bmpm_data/gen_approx_arabic.txt +26 -0
- data/data/bmpm_data/gen_approx_common.txt +233 -0
- data/data/bmpm_data/gen_approx_cyrillic.txt +18 -0
- data/data/bmpm_data/gen_approx_czech.txt +18 -0
- data/data/bmpm_data/gen_approx_dutch.txt +18 -0
- data/data/bmpm_data/gen_approx_english.txt +47 -0
- data/data/bmpm_data/gen_approx_french.txt +25 -0
- data/data/bmpm_data/gen_approx_german.txt +73 -0
- data/data/bmpm_data/gen_approx_greek.txt +18 -0
- data/data/bmpm_data/gen_approx_greeklatin.txt +20 -0
- data/data/bmpm_data/gen_approx_hebrew.txt +18 -0
- data/data/bmpm_data/gen_approx_hungarian.txt +18 -0
- data/data/bmpm_data/gen_approx_italian.txt +18 -0
- data/data/bmpm_data/gen_approx_polish.txt +84 -0
- data/data/bmpm_data/gen_approx_portuguese.txt +18 -0
- data/data/bmpm_data/gen_approx_romanian.txt +18 -0
- data/data/bmpm_data/gen_approx_russian.txt +48 -0
- data/data/bmpm_data/gen_approx_spanish.txt +21 -0
- data/data/bmpm_data/gen_approx_turkish.txt +18 -0
- data/data/bmpm_data/gen_exact_any.txt +40 -0
- data/data/bmpm_data/gen_exact_approx_common.txt +79 -0
- data/data/bmpm_data/gen_exact_arabic.txt +18 -0
- data/data/bmpm_data/gen_exact_common.txt +32 -0
- data/data/bmpm_data/gen_exact_cyrillic.txt +18 -0
- data/data/bmpm_data/gen_exact_czech.txt +18 -0
- data/data/bmpm_data/gen_exact_dutch.txt +18 -0
- data/data/bmpm_data/gen_exact_english.txt +18 -0
- data/data/bmpm_data/gen_exact_french.txt +18 -0
- data/data/bmpm_data/gen_exact_german.txt +18 -0
- data/data/bmpm_data/gen_exact_greek.txt +18 -0
- data/data/bmpm_data/gen_exact_greeklatin.txt +18 -0
- data/data/bmpm_data/gen_exact_hebrew.txt +18 -0
- data/data/bmpm_data/gen_exact_hungarian.txt +18 -0
- data/data/bmpm_data/gen_exact_italian.txt +18 -0
- data/data/bmpm_data/gen_exact_polish.txt +23 -0
- data/data/bmpm_data/gen_exact_portuguese.txt +18 -0
- data/data/bmpm_data/gen_exact_romanian.txt +18 -0
- data/data/bmpm_data/gen_exact_russian.txt +19 -0
- data/data/bmpm_data/gen_exact_spanish.txt +19 -0
- data/data/bmpm_data/gen_exact_turkish.txt +18 -0
- data/data/bmpm_data/gen_hebrew_common.txt +113 -0
- data/data/bmpm_data/gen_lang.txt +295 -0
- data/data/bmpm_data/gen_languages.txt +36 -0
- data/data/bmpm_data/gen_rules_any.txt +367 -0
- data/data/bmpm_data/gen_rules_arabic.txt +76 -0
- data/data/bmpm_data/gen_rules_cyrillic.txt +99 -0
- data/data/bmpm_data/gen_rules_czech.txt +67 -0
- data/data/bmpm_data/gen_rules_dutch.txt +78 -0
- data/data/bmpm_data/gen_rules_english.txt +113 -0
- data/data/bmpm_data/gen_rules_french.txt +114 -0
- data/data/bmpm_data/gen_rules_german.txt +129 -0
- data/data/bmpm_data/gen_rules_greek.txt +97 -0
- data/data/bmpm_data/gen_rules_greeklatin.txt +118 -0
- data/data/bmpm_data/gen_rules_hebrew.txt +62 -0
- data/data/bmpm_data/gen_rules_hungarian.txt +83 -0
- data/data/bmpm_data/gen_rules_italian.txt +77 -0
- data/data/bmpm_data/gen_rules_polish.txt +185 -0
- data/data/bmpm_data/gen_rules_portuguese.txt +105 -0
- data/data/bmpm_data/gen_rules_romanian.txt +64 -0
- data/data/bmpm_data/gen_rules_russian.txt +142 -0
- data/data/bmpm_data/gen_rules_spanish.txt +85 -0
- data/data/bmpm_data/gen_rules_turkish.txt +50 -0
- data/data/keyboard_data/qwerty.npy +0 -0
- data/data/matrix_data/BLOSUM100 +31 -0
- data/data/matrix_data/BLOSUM30 +31 -0
- data/data/matrix_data/BLOSUM35 +31 -0
- data/data/matrix_data/BLOSUM40 +31 -0
- data/data/matrix_data/BLOSUM45 +25 -0
- data/data/matrix_data/BLOSUM50 +25 -0
- data/data/matrix_data/BLOSUM55 +31 -0
- data/data/matrix_data/BLOSUM60 +31 -0
- data/data/matrix_data/BLOSUM62 +25 -0
- data/data/matrix_data/BLOSUM65 +31 -0
- data/data/matrix_data/BLOSUM70 +31 -0
- data/data/matrix_data/BLOSUM75 +31 -0
- data/data/matrix_data/BLOSUM80 +25 -0
- data/data/matrix_data/BLOSUM85 +31 -0
- data/data/matrix_data/BLOSUM90 +25 -0
- data/data/matrix_data/NUC.4.4 +25 -0
- data/data/matrix_data/PAM10 +34 -0
- data/data/matrix_data/PAM100 +34 -0
- data/data/matrix_data/PAM110 +34 -0
- data/data/matrix_data/PAM120 +34 -0
- data/data/matrix_data/PAM130 +34 -0
- data/data/matrix_data/PAM140 +34 -0
- data/data/matrix_data/PAM150 +34 -0
- data/data/matrix_data/PAM160 +34 -0
- data/data/matrix_data/PAM170 +34 -0
- data/data/matrix_data/PAM180 +34 -0
- data/data/matrix_data/PAM190 +34 -0
- data/data/matrix_data/PAM20 +34 -0
- data/data/matrix_data/PAM200 +34 -0
- data/data/matrix_data/PAM210 +34 -0
- data/data/matrix_data/PAM220 +34 -0
- data/data/matrix_data/PAM230 +34 -0
- data/data/matrix_data/PAM240 +34 -0
- data/data/matrix_data/PAM250 +25 -0
- data/data/matrix_data/PAM260 +34 -0
- data/data/matrix_data/PAM270 +34 -0
- data/data/matrix_data/PAM280 +34 -0
- data/data/matrix_data/PAM290 +34 -0
- data/data/matrix_data/PAM30 +25 -0
- data/data/matrix_data/PAM300 +34 -0
- data/data/matrix_data/PAM310 +34 -0
- data/data/matrix_data/PAM320 +34 -0
- data/data/matrix_data/PAM330 +34 -0
- data/data/matrix_data/PAM340 +34 -0
- data/data/matrix_data/PAM350 +34 -0
- data/data/matrix_data/PAM360 +34 -0
- data/data/matrix_data/PAM370 +34 -0
- data/data/matrix_data/PAM380 +34 -0
- data/data/matrix_data/PAM390 +34 -0
- data/data/matrix_data/PAM40 +34 -0
- data/data/matrix_data/PAM400 +34 -0
- data/data/matrix_data/PAM410 +34 -0
- data/data/matrix_data/PAM420 +34 -0
- data/data/matrix_data/PAM430 +34 -0
- data/data/matrix_data/PAM440 +34 -0
- data/data/matrix_data/PAM450 +34 -0
- data/data/matrix_data/PAM460 +34 -0
- data/data/matrix_data/PAM470 +34 -0
- data/data/matrix_data/PAM480 +34 -0
- data/data/matrix_data/PAM490 +34 -0
- data/data/matrix_data/PAM50 +34 -0
- data/data/matrix_data/PAM500 +34 -0
- data/data/matrix_data/PAM60 +34 -0
- data/data/matrix_data/PAM70 +25 -0
- data/data/matrix_data/PAM80 +34 -0
- data/data/matrix_data/PAM90 +34 -0
- data/ext/stride_align/backend_avx2.cpp +2 -0
- data/ext/stride_align/backend_avx512bwvl.cpp +2 -0
- data/ext/stride_align/backend_generic.cpp +3 -0
- data/ext/stride_align/backend_impl.hpp +983 -0
- data/ext/stride_align/backend_lasx.cpp +2 -0
- data/ext/stride_align/backend_lsx.cpp +2 -0
- data/ext/stride_align/backend_neon.cpp +2 -0
- data/ext/stride_align/backend_rvv.cpp +2 -0
- data/ext/stride_align/backend_sse41.cpp +2 -0
- data/ext/stride_align/backend_sve.cpp +2 -0
- data/ext/stride_align/backend_sve2.cpp +2 -0
- data/ext/stride_align/backend_vsx.cpp +2 -0
- data/ext/stride_align/beider_morse_impl.cpp +5 -0
- data/ext/stride_align/cpu_detect.cpp +172 -0
- data/ext/stride_align/cpu_detect.hpp +6 -0
- data/ext/stride_align/extconf.rb +114 -0
- data/ext/stride_align/target_profile.hpp +82 -0
- data/ext/stride_align/vendor/beider_morse_impl.cpp +1467 -0
- data/ext/stride_align/vendor/stride_align/alignment.hpp +199 -0
- data/ext/stride_align/vendor/stride_align/batch.hpp +812 -0
- data/ext/stride_align/vendor/stride_align/beider_morse.hpp +121 -0
- data/ext/stride_align/vendor/stride_align/caverphone.hpp +222 -0
- data/ext/stride_align/vendor/stride_align/cologne_phonetic.hpp +202 -0
- data/ext/stride_align/vendor/stride_align/core.hpp +731 -0
- data/ext/stride_align/vendor/stride_align/daitch_mokotoff.hpp +631 -0
- data/ext/stride_align/vendor/stride_align/double_metaphone.hpp +796 -0
- data/ext/stride_align/vendor/stride_align/dtw.hpp +300 -0
- data/ext/stride_align/vendor/stride_align/encoded.hpp +235 -0
- data/ext/stride_align/vendor/stride_align/hamming.hpp +55 -0
- data/ext/stride_align/vendor/stride_align/indel.hpp +1200 -0
- data/ext/stride_align/vendor/stride_align/jaro.hpp +517 -0
- data/ext/stride_align/vendor/stride_align/lcs.hpp +159 -0
- data/ext/stride_align/vendor/stride_align/levenshtein.hpp +1247 -0
- data/ext/stride_align/vendor/stride_align/levenshtein_prepared.hpp +193 -0
- data/ext/stride_align/vendor/stride_align/match_rating.hpp +168 -0
- data/ext/stride_align/vendor/stride_align/metaphone.hpp +291 -0
- data/ext/stride_align/vendor/stride_align/ngram.hpp +176 -0
- data/ext/stride_align/vendor/stride_align/nysiis.hpp +199 -0
- data/ext/stride_align/vendor/stride_align/pairwise_alignment.hpp +465 -0
- data/ext/stride_align/vendor/stride_align/partial_ratio.hpp +486 -0
- data/ext/stride_align/vendor/stride_align/ratcliff_obershelp.hpp +101 -0
- data/ext/stride_align/vendor/stride_align/soundex.hpp +108 -0
- data/ext/stride_align/vendor/stride_align/token_ratios.hpp +445 -0
- data/ext/stride_align/vendor/stride_align/types.hpp +16 -0
- data/ext/stride_align/vendor/stride_align/utf8.hpp +512 -0
- data/ext/stride_align/vendor/stride_align/wratio.hpp +363 -0
- data/lib/stride_align/algorithms.rb +296 -0
- data/lib/stride_align/alignment_path.rb +217 -0
- data/lib/stride_align/backend.rb +47 -0
- data/lib/stride_align/batch.rb +705 -0
- data/lib/stride_align/core.rb +180 -0
- data/lib/stride_align/keyboard.rb +200 -0
- data/lib/stride_align/matrices.rb +403 -0
- data/lib/stride_align/version.rb +5 -0
- data/lib/stride_align.rb +87 -0
- 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
|