active_sanction 1.0.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/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
# Turns a candidate into an explainable score. Stage 4 of the matching
|
|
8
|
+
# pipeline, and the component that decides whether this library is
|
|
9
|
+
# trustworthy.
|
|
10
|
+
#
|
|
11
|
+
# subject = ActiveSanction::Scorer::Subject.new(
|
|
12
|
+
# name: "Abu Abbas", type: :individual, dates_of_birth: "1948", nationalities: %w[RU]
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# result = ActiveSanction::Scorer.call(subject, entity)
|
|
16
|
+
# result.score # => 87.4
|
|
17
|
+
# result.name.value # => "ABBAS, Abu"
|
|
18
|
+
# result.explanation
|
|
19
|
+
# # => [#<Reason +91.2 name: matched primary name 'ABBAS, Abu'>,
|
|
20
|
+
# # #<Reason +6.0 dob: date of birth 1948 overlaps listed 1948-12-10>,
|
|
21
|
+
# # #<Reason -12.0 nationality: query RU vs listed EG>]
|
|
22
|
+
#
|
|
23
|
+
# ActiveSanction::Scorer.call(subject, index.candidates("Abu Abbas").first)
|
|
24
|
+
#
|
|
25
|
+
# Everything upstream narrows: the normalizer (#26) makes two names
|
|
26
|
+
# comparable, the index (#31) says which are worth comparing, and Similarity
|
|
27
|
+
# (#28, #29) and Phonetics (#30) each answer one question about a pair. This
|
|
28
|
+
# stage is the only one that says *how much a match is worth*, and the only
|
|
29
|
+
# one whose output a person has to act on.
|
|
30
|
+
#
|
|
31
|
+
# ### An entity's score is the best of its names
|
|
32
|
+
#
|
|
33
|
+
# Matching any single alias is a hit. OFAC ships 20,147 aliases against
|
|
34
|
+
# 19,321 primary names and the UN publishes as many as a dozen spellings of
|
|
35
|
+
# one person, so a score that averaged over an entity's names would punish
|
|
36
|
+
# the records that describe themselves most thoroughly -- and a score that
|
|
37
|
+
# only read the primary name would miss most of what these lists are for.
|
|
38
|
+
#
|
|
39
|
+
# So every name is scored and the best one wins, and the winner is on the
|
|
40
|
+
# Result: a report has to be able to say which spelling produced the hit.
|
|
41
|
+
#
|
|
42
|
+
# The low-quality penalty is applied *before* the maximum rather than to the
|
|
43
|
+
# winner afterwards, which is the difference between two readings of the
|
|
44
|
+
# same rule. The UN grades some aliases `Low`, meaning the Committee itself
|
|
45
|
+
# is unsure the person is known by that name; a good name scoring 85 should
|
|
46
|
+
# beat a low-quality one scoring 90, and it only does if the penalty is part
|
|
47
|
+
# of what the maximum is taken over.
|
|
48
|
+
#
|
|
49
|
+
# A former name (`fka`) is not penalized. It is a name the person really
|
|
50
|
+
# used, and a screening tool that discounted it would be discounting exactly
|
|
51
|
+
# the alias someone changes their name to escape.
|
|
52
|
+
#
|
|
53
|
+
# ### An entity of the wrong type is not scored at all
|
|
54
|
+
#
|
|
55
|
+
# A subject that says `:individual` is never compared to a vessel, whatever
|
|
56
|
+
# the names look like. `NORTHERN STAR` is a ship and a person and the
|
|
57
|
+
# difference is not a matter of degree -- there is no score at which a
|
|
58
|
+
# compliance officer wants a ship in a list of people -- so this is a filter
|
|
59
|
+
# and not a penalty, and `call` returns nil.
|
|
60
|
+
#
|
|
61
|
+
# Vessels and aircraft are ~10% of the SDN list and carry name-like strings,
|
|
62
|
+
# which is why Entity has the types at all. A subject that gives no type is
|
|
63
|
+
# asking a different question and is scored against everything.
|
|
64
|
+
#
|
|
65
|
+
# ### Deterministic, to the last decimal
|
|
66
|
+
#
|
|
67
|
+
# The same subject and the same entity produce the same score and the same
|
|
68
|
+
# explanation on every run, in every process. That is not a nicety: a
|
|
69
|
+
# screening decision is re-derived during an audit months later, and a score
|
|
70
|
+
# that moved by a tenth because a Hash iterated differently is a decision
|
|
71
|
+
# nobody can defend.
|
|
72
|
+
#
|
|
73
|
+
# What that costs is small and worth naming, because each piece of it is a
|
|
74
|
+
# place the property could be lost. Names are scored in the order the
|
|
75
|
+
# publisher listed them and ties go to the first, so an entity carrying the
|
|
76
|
+
# same spelling twice does not depend on which copy was seen first. The
|
|
77
|
+
# adjustments run in a fixed order -- see Adjustments -- and each picks its
|
|
78
|
+
# pairing by iteration order rather than by anything sorted on a Float.
|
|
79
|
+
# Rounding happens once, in Reason, and the score is the sum of the rounded
|
|
80
|
+
# contributions rather than the rounded sum.
|
|
81
|
+
#
|
|
82
|
+
# ### `threshold:` is how a screening call fits in its budget
|
|
83
|
+
#
|
|
84
|
+
# This stage is nearly all of what a screening call costs -- a few hundred
|
|
85
|
+
# candidates, four string algorithms and a phonetic pass on each -- and
|
|
86
|
+
# scoring them exactly costs about 46 ms under YJIT where scoring them to a
|
|
87
|
+
# threshold of 75 costs about 16. The difference is not an approximation:
|
|
88
|
+
# everything a threshold turns off is a comparison whose result could not
|
|
89
|
+
# have changed the answer, and a result at or above the cutoff is exactly
|
|
90
|
+
# the result the same call without one returns. See NameScore for the
|
|
91
|
+
# arithmetic and `rake benchmark:scorer` for the sweep.
|
|
92
|
+
#
|
|
93
|
+
# So the caller in front of this should always pass one. A matcher that
|
|
94
|
+
# screens without a threshold spends three times its budget computing exact
|
|
95
|
+
# scores for candidates it is about to discard.
|
|
96
|
+
#
|
|
97
|
+
# It is applied to the whole score rather than to the name, which matters:
|
|
98
|
+
# a subject carrying the right passport number needs forty points less of a
|
|
99
|
+
# name than one carrying nothing, and a threshold applied to the name alone
|
|
100
|
+
# would drop exactly the hits the identifiers exist to find.
|
|
101
|
+
#
|
|
102
|
+
# ### Weights
|
|
103
|
+
#
|
|
104
|
+
# Every number this stage uses is in Weights, with a default and the reason
|
|
105
|
+
# for it. A host that disagrees passes its own, per call or in
|
|
106
|
+
# configuration:
|
|
107
|
+
#
|
|
108
|
+
# ActiveSanction.configure { |c| c.scorer_weights = { dob_conflict: -20.0 } }
|
|
109
|
+
#
|
|
110
|
+
# Changing them changes what a past decision would score today, so a stored
|
|
111
|
+
# decision records the weights it was made under -- which is #33's job, and
|
|
112
|
+
# the reason this stage takes them as an argument rather than reading a
|
|
113
|
+
# global halfway down a call stack.
|
|
114
|
+
module Scorer
|
|
115
|
+
extend T::Sig
|
|
116
|
+
extend T::Helpers
|
|
117
|
+
|
|
118
|
+
# Called as `Scorer.threshold!`, which is where `raise` comes from.
|
|
119
|
+
requires_ancestor { Kernel }
|
|
120
|
+
|
|
121
|
+
# The scale everything here works in. Similarity is 0..1; the conversion
|
|
122
|
+
# happens once, in NameScore.
|
|
123
|
+
#
|
|
124
|
+
# @api private
|
|
125
|
+
SCALE = T.let(100.0, Float)
|
|
126
|
+
|
|
127
|
+
module_function
|
|
128
|
+
|
|
129
|
+
# The best score this entity can make against this subject, or nil when
|
|
130
|
+
# there is nothing to score: an entity of the wrong type, or one whose
|
|
131
|
+
# every name folds away to nothing.
|
|
132
|
+
#
|
|
133
|
+
# Takes an Entity or an Index::Candidate, since the caller in front of
|
|
134
|
+
# this holds candidates and the caller in a console holds entities.
|
|
135
|
+
#
|
|
136
|
+
# `weights:` defaults to the configured set. It is read once per call
|
|
137
|
+
# rather than per name, so a configuration changed mid-call cannot produce
|
|
138
|
+
# a score that is half one set of weights and half another.
|
|
139
|
+
#
|
|
140
|
+
# `threshold:` is on the same 0..100 scale as the score. A result at or
|
|
141
|
+
# above it is exactly the result the same call without one returns; below
|
|
142
|
+
# it, nil. See the note on cost below for what it buys and why the caller
|
|
143
|
+
# in front of this should always pass one.
|
|
144
|
+
sig do
|
|
145
|
+
params(subject: Subject, candidate: T.untyped, weights: T.untyped, threshold: Numeric)
|
|
146
|
+
.returns(T.nilable(Result)).checked(:tests)
|
|
147
|
+
end
|
|
148
|
+
def call(subject, candidate, weights: nil, threshold: 0.0)
|
|
149
|
+
entity = candidate.is_a?(Entity) ? candidate : candidate.entity
|
|
150
|
+
return nil unless comparable?(subject, entity)
|
|
151
|
+
|
|
152
|
+
settings = Weights.build(weights || ActiveSanction.config.scorer_weights)
|
|
153
|
+
cutoff = threshold!(threshold)
|
|
154
|
+
# The secondary identifiers first, though they are reported second: they
|
|
155
|
+
# cost no string comparison, and what they come to is what the name has
|
|
156
|
+
# to beat. A subject carrying the right passport number needs 40 points
|
|
157
|
+
# less of a name than one carrying nothing.
|
|
158
|
+
adjustments = Adjustments.call(subject, entity, settings)
|
|
159
|
+
best = best_name(subject, entity, settings, floor(cutoff, adjustments))
|
|
160
|
+
return nil if best.nil?
|
|
161
|
+
|
|
162
|
+
name, form, reasons = best
|
|
163
|
+
result = Result.new(entity: entity, name: name, form: form,
|
|
164
|
+
explanation: bounded(reasons + adjustments))
|
|
165
|
+
result.score < cutoff ? nil : result
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The least a name can score and still leave the entity able to reach the
|
|
169
|
+
# cutoff, given what the identifiers already came to.
|
|
170
|
+
#
|
|
171
|
+
# A cutoff of nothing is nothing, and the guard is not cosmetic: a stack
|
|
172
|
+
# of penalties can put a total below zero, where the floor clamps it back
|
|
173
|
+
# up, so `cutoff - adjustments` would demand a name score of 47 to reach a
|
|
174
|
+
# threshold of 0 and quietly return no result at all.
|
|
175
|
+
sig { params(cutoff: Float, adjustments: T::Array[Reason]).returns(Float).checked(:tests) }
|
|
176
|
+
def floor(cutoff, adjustments)
|
|
177
|
+
return 0.0 unless cutoff.positive?
|
|
178
|
+
|
|
179
|
+
[cutoff - adjustments.sum(&:contribution), 0.0].max
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A threshold as a Float, or a QueryError.
|
|
183
|
+
#
|
|
184
|
+
# This is the boundary where the library changes units: everything below
|
|
185
|
+
# this stage is a similarity on a 0..1 scale and everything above it is a
|
|
186
|
+
# percentage. Similarity checks for the mistake in one direction -- an 85
|
|
187
|
+
# arriving where 0.85 was meant, which would reject every pair and read as
|
|
188
|
+
# "nothing matched" -- and this checks the other, an argument outside
|
|
189
|
+
# 0..100 at all.
|
|
190
|
+
#
|
|
191
|
+
# What it deliberately cannot catch is a `0.75` meant as three-quarters,
|
|
192
|
+
# because 0.75 is a legitimate threshold and there is no way to tell the
|
|
193
|
+
# two apart. That mistake is the survivable one: a threshold far too low
|
|
194
|
+
# returns everything the index found rather than nothing, which is noisy
|
|
195
|
+
# and visible. It is the opposite error that hides a hit.
|
|
196
|
+
sig { params(threshold: Numeric).returns(Float).checked(:tests) }
|
|
197
|
+
def threshold!(threshold)
|
|
198
|
+
cutoff = threshold.to_f
|
|
199
|
+
return cutoff if cutoff.between?(0.0, SCALE)
|
|
200
|
+
|
|
201
|
+
raise QueryError,
|
|
202
|
+
"threshold must be between 0 and 100, got #{threshold.inspect} -- " \
|
|
203
|
+
"a screening score is a percentage, not a similarity on a 0..1 scale"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Whether this entity is the kind of thing the subject asked about. A
|
|
207
|
+
# subject with no type asks about everything.
|
|
208
|
+
sig { params(subject: Subject, entity: Entity).returns(T::Boolean).checked(:tests) }
|
|
209
|
+
def comparable?(subject, entity) = subject.type.nil? || subject.type == entity.type
|
|
210
|
+
|
|
211
|
+
# One of an entity's names, as far as this stage takes it: the published
|
|
212
|
+
# name, its folded form, and the reasons that name produced.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
215
|
+
Scored = T.type_alias { [Name, Normalizer::Form, T::Array[Reason]] }
|
|
216
|
+
|
|
217
|
+
# Whichever of the entity's names scores highest once its own quality
|
|
218
|
+
# penalty is applied, or nil when none of them survives the fold or
|
|
219
|
+
# reaches the threshold.
|
|
220
|
+
#
|
|
221
|
+
# Each name is scored against the best one found so far as well as against
|
|
222
|
+
# the caller's threshold, since only the best is going to be reported. On
|
|
223
|
+
# an entity with a dozen aliases that is most of the work skipped: the
|
|
224
|
+
# second name only has to be compared closely enough to establish that it
|
|
225
|
+
# does not beat the first.
|
|
226
|
+
#
|
|
227
|
+
# Equal is not better, so a tie stays with the name the publisher listed
|
|
228
|
+
# first -- see the note on determinism above.
|
|
229
|
+
sig do
|
|
230
|
+
params(subject: Subject, entity: Entity, weights: Weights, threshold: Float)
|
|
231
|
+
.returns(T.nilable(Scored)).checked(:tests)
|
|
232
|
+
end
|
|
233
|
+
def best_name(subject, entity, weights, threshold)
|
|
234
|
+
best = T.let(nil, T.nilable(Scored))
|
|
235
|
+
highest = T.let(-Float::INFINITY, Float)
|
|
236
|
+
entity.names.each do |name|
|
|
237
|
+
# A name that folds away to nothing cannot be compared -- see
|
|
238
|
+
# Normalizer::Form#empty?, and Index, which skips the same names.
|
|
239
|
+
form = Normalizer.call(name, type: entity.type)
|
|
240
|
+
next if form.empty?
|
|
241
|
+
|
|
242
|
+
penalty = name.low_quality? ? weights.low_quality_alias : 0.0
|
|
243
|
+
cutoff = [threshold, highest].max - penalty
|
|
244
|
+
score = NameScore.call(subject.form, form, weights, threshold: cutoff.clamp(0.0, SCALE))
|
|
245
|
+
# A zero under a real cutoff means "could not reach it", not "scored
|
|
246
|
+
# nothing" -- and the two have to be told apart before the comparison
|
|
247
|
+
# below, which would otherwise take a name that was never measured.
|
|
248
|
+
next if score.zero? && cutoff.positive?
|
|
249
|
+
next unless score + penalty > highest
|
|
250
|
+
|
|
251
|
+
highest = score + penalty
|
|
252
|
+
best = [name, form, reasons(name, score, penalty)]
|
|
253
|
+
end
|
|
254
|
+
best
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# What one name is worth: the blended similarity, and the penalty for a
|
|
258
|
+
# name its own publisher graded unreliable.
|
|
259
|
+
sig do
|
|
260
|
+
params(name: Name, score: Float, penalty: Float).returns(T::Array[Reason]).checked(:tests)
|
|
261
|
+
end
|
|
262
|
+
def reasons(name, score, penalty)
|
|
263
|
+
reasons = [Reason.new(factor: :name, detail: matched(name), contribution: score)]
|
|
264
|
+
return reasons if penalty.zero?
|
|
265
|
+
|
|
266
|
+
reasons << Reason.new(factor: :alias_quality, contribution: penalty,
|
|
267
|
+
detail: "#{name.value.inspect} is graded a low-quality alias by its publisher")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
sig { params(name: Name).returns(String).checked(:tests) }
|
|
271
|
+
def matched(name)
|
|
272
|
+
kind = name.primary? ? "primary name" : "alias"
|
|
273
|
+
suffix = name.primary? ? "" : " (#{name.kind})"
|
|
274
|
+
"matched #{kind} #{name.value.inspect}#{suffix}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Keeps the score inside 0..100 without letting the explanation stop
|
|
278
|
+
# explaining it. A cap that silently swallowed 12 points would leave a
|
|
279
|
+
# reviewer adding a column of figures that does not reach the number
|
|
280
|
+
# printed above it, so the correction is itself a reason.
|
|
281
|
+
#
|
|
282
|
+
# It fires rarely -- a decisive identifier boost on an already-strong name
|
|
283
|
+
# is the usual way -- and when it does, the fact that a score reached its
|
|
284
|
+
# ceiling with points to spare is worth seeing.
|
|
285
|
+
sig { params(reasons: T::Array[Reason]).returns(T::Array[Reason]).checked(:tests) }
|
|
286
|
+
def bounded(reasons)
|
|
287
|
+
total = reasons.sum(&:contribution).round(Reason::PRECISION)
|
|
288
|
+
capped = total.clamp(0.0, SCALE)
|
|
289
|
+
return reasons if capped == total
|
|
290
|
+
|
|
291
|
+
reasons + [Reason.new(factor: :clamp, contribution: capped - total,
|
|
292
|
+
detail: "#{total.round(Reason::PRECISION)} #{capped.zero? ? "raised" : "capped"} " \
|
|
293
|
+
"to #{capped}")]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
require "active_sanction/scorer/weights"
|
|
299
|
+
require "active_sanction/scorer/reason"
|
|
300
|
+
require "active_sanction/scorer/subject"
|
|
301
|
+
require "active_sanction/scorer/name_score"
|
|
302
|
+
require "active_sanction/scorer/adjustments"
|
|
303
|
+
require "active_sanction/scorer/result"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Similarity
|
|
8
|
+
# Jaro-Winkler similarity, 0..1.
|
|
9
|
+
#
|
|
10
|
+
# ActiveSanction::Similarity::JaroWinkler.call("martha", "marhta") # => 0.9611
|
|
11
|
+
# ActiveSanction::Similarity::JaroWinkler.call("dixon", "dicksonx") # => 0.8133
|
|
12
|
+
#
|
|
13
|
+
# ### Why this one
|
|
14
|
+
#
|
|
15
|
+
# Jaro counts the characters two strings share within a window that widens
|
|
16
|
+
# with their length, and charges half an edit for each pair that matched
|
|
17
|
+
# out of order. That is a good model of how names are actually
|
|
18
|
+
# mistyped -- `MARHTA` for `MARTHA` is one transposition and two
|
|
19
|
+
# substitutions to Levenshtein, which scores it 0.667 against Jaro's 0.944.
|
|
20
|
+
#
|
|
21
|
+
# Winkler's addition is a bonus for a shared prefix, on the observation
|
|
22
|
+
# that people get the beginning of a name right and drift later. Names are
|
|
23
|
+
# the case that observation was drawn from and the case it holds best for:
|
|
24
|
+
# a transliterator's choice of vowel, a clerk's spelling of a suffix, and
|
|
25
|
+
# a truncated field all differ at the end.
|
|
26
|
+
#
|
|
27
|
+
# The prefix bonus is also the reason this is not the only scorer in the
|
|
28
|
+
# pipeline. It rewards `SMITH` against `SMITHSON`, and it has nothing at
|
|
29
|
+
# all to say about `ABBAS, Abu` against `Abu Abbas`, which shares no
|
|
30
|
+
# prefix and is the single most common query shape against these lists.
|
|
31
|
+
# TokenSort and TokenSet are what answer that.
|
|
32
|
+
module JaroWinkler
|
|
33
|
+
extend T::Sig
|
|
34
|
+
|
|
35
|
+
# Winkler's constants, and they are his: 0.1 with a four-character cap
|
|
36
|
+
# is what the 1990 paper used and what every published reference value
|
|
37
|
+
# is computed against. `p * l` cannot exceed 0.4, which is what keeps
|
|
38
|
+
# the bonus from pushing a score past 1.0.
|
|
39
|
+
PREFIX_SCALE = T.let(0.1, Float)
|
|
40
|
+
MAX_PREFIX = T.let(4, Integer)
|
|
41
|
+
|
|
42
|
+
# The bonus applies only to pairs that already look alike, which is
|
|
43
|
+
# Winkler's own rule and worth keeping for a reason this domain cares
|
|
44
|
+
# about: without it, every name beginning `MOHAMMED` is pulled toward
|
|
45
|
+
# every other one regardless of what follows, and a list where a quarter
|
|
46
|
+
# of the entries share a given name is exactly where that shows up as
|
|
47
|
+
# false positives.
|
|
48
|
+
BOOST_THRESHOLD = T.let(0.7, Float)
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# The similarity of two already-folded strings -- see Similarity for
|
|
53
|
+
# what `threshold:` does and what it promises.
|
|
54
|
+
sig { params(left: String, right: String, threshold: Numeric).returns(Float).checked(:tests) }
|
|
55
|
+
def call(left, right, threshold: 0.0)
|
|
56
|
+
cutoff = Similarity.threshold!(threshold)
|
|
57
|
+
return 1.0 if left == right
|
|
58
|
+
|
|
59
|
+
left_codes = Similarity.codepoints(left)
|
|
60
|
+
right_codes = Similarity.codepoints(right)
|
|
61
|
+
return 0.0 if ceiling(left_codes.size, right_codes.size) < cutoff
|
|
62
|
+
|
|
63
|
+
score = winkler(left_codes, right_codes, jaro_score(left_codes, right_codes))
|
|
64
|
+
score < cutoff ? 0.0 : score
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The highest score two strings of these lengths can reach, whatever
|
|
68
|
+
# they contain. This is the early exit: a pair whose ceiling is under
|
|
69
|
+
# the caller's threshold is rejected without either string being looked
|
|
70
|
+
# at.
|
|
71
|
+
#
|
|
72
|
+
# At most `shorter` characters can match, so the two coverage terms are
|
|
73
|
+
# bounded by 1 and `shorter / longer` and the transposition term by 1;
|
|
74
|
+
# the prefix bonus on top can be no larger than a prefix of `shorter`
|
|
75
|
+
# allows. The bound is loose -- a 0.85 threshold only rejects a pair
|
|
76
|
+
# whose lengths differ by more than 4x, because the prefix bonus is
|
|
77
|
+
# generous about it -- and loose is the only safe direction. A ceiling
|
|
78
|
+
# that ever came in under a real score would drop true matches, so it is
|
|
79
|
+
# derived rather than tuned, and the specs hold it to that against every
|
|
80
|
+
# pair they can build.
|
|
81
|
+
sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
|
|
82
|
+
def ceiling(left_length, right_length)
|
|
83
|
+
shorter = [left_length, right_length].min
|
|
84
|
+
longer = [left_length, right_length].max
|
|
85
|
+
return shorter == longer ? 1.0 : 0.0 if shorter.zero?
|
|
86
|
+
|
|
87
|
+
jaro = (2.0 + shorter.fdiv(longer)) / 3.0
|
|
88
|
+
jaro + ([shorter, MAX_PREFIX].min * PREFIX_SCALE * (1.0 - jaro))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Jaro on its own, without the prefix bonus. Public because it is the
|
|
92
|
+
# published quantity -- a reference table gives both -- and because the
|
|
93
|
+
# scorer may yet want the unboosted number for a pair whose shared
|
|
94
|
+
# prefix is the part a caller has least confidence in.
|
|
95
|
+
sig { params(left: String, right: String).returns(Float).checked(:tests) }
|
|
96
|
+
def jaro(left, right)
|
|
97
|
+
return 1.0 if left == right
|
|
98
|
+
|
|
99
|
+
jaro_score(Similarity.codepoints(left), Similarity.codepoints(right))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Codepoints in, for the reason Similarity.codepoints gives. `left` and
|
|
103
|
+
# `right` mean the same two names here as they do above; only the
|
|
104
|
+
# representation changes, and the signatures say which is which.
|
|
105
|
+
sig { params(left: T::Array[Integer], right: T::Array[Integer]).returns(Float).checked(:tests) }
|
|
106
|
+
def jaro_score(left, right)
|
|
107
|
+
return 0.0 if left.empty? || right.empty?
|
|
108
|
+
|
|
109
|
+
window = ([left.size, right.size].max / 2) - 1
|
|
110
|
+
window = 0 if window.negative?
|
|
111
|
+
left_matched, right_matched, matches = match(left, right, window)
|
|
112
|
+
return 0.0 if matches.zero?
|
|
113
|
+
|
|
114
|
+
halved = transpositions(left, right, left_matched, right_matched) / 2.0
|
|
115
|
+
(matches.fdiv(left.size) + matches.fdiv(right.size) + ((matches - halved) / matches)) / 3.0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Which characters of each string found a partner in the other, and how
|
|
119
|
+
# many did.
|
|
120
|
+
#
|
|
121
|
+
# A character matches at most once, and only within `window` positions
|
|
122
|
+
# of where it sits in the other string -- that window is what makes this
|
|
123
|
+
# a similarity between two names rather than a bag-of-letters count, and
|
|
124
|
+
# what keeps `ORWELL` from scoring highly against `LLEWRO`.
|
|
125
|
+
sig do
|
|
126
|
+
params(left: T::Array[Integer], right: T::Array[Integer], window: Integer)
|
|
127
|
+
.returns([T::Array[T::Boolean], T::Array[T::Boolean], Integer])
|
|
128
|
+
.checked(:tests)
|
|
129
|
+
end
|
|
130
|
+
def match(left, right, window)
|
|
131
|
+
left_matched = Array.new(left.size, false)
|
|
132
|
+
right_matched = Array.new(right.size, false)
|
|
133
|
+
last = right.size - 1
|
|
134
|
+
matches = 0
|
|
135
|
+
left.each_with_index do |code, i|
|
|
136
|
+
low = i > window ? i - window : 0
|
|
137
|
+
j = partner(right, right_matched, code, low, [i + window, last].min)
|
|
138
|
+
next if j.nil?
|
|
139
|
+
|
|
140
|
+
left_matched[i] = true
|
|
141
|
+
right_matched[j] = true
|
|
142
|
+
matches += 1
|
|
143
|
+
end
|
|
144
|
+
[left_matched, right_matched, matches]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The first position in `right[low..high]` holding `code` and not already
|
|
148
|
+
# spoken for, or nil. Leftmost, which is what makes the pairing
|
|
149
|
+
# deterministic: `call(x, y)` has to return the same number every time
|
|
150
|
+
# it is asked, because a screening decision is re-derived during an
|
|
151
|
+
# audit.
|
|
152
|
+
sig do
|
|
153
|
+
params(right: T::Array[Integer], matched: T::Array[T::Boolean], code: Integer, low: Integer, high: Integer)
|
|
154
|
+
.returns(T.nilable(Integer))
|
|
155
|
+
.checked(:tests)
|
|
156
|
+
end
|
|
157
|
+
def partner(right, matched, code, low, high)
|
|
158
|
+
j = low
|
|
159
|
+
while j <= high
|
|
160
|
+
return j if !matched[j] && right.fetch(j) == code
|
|
161
|
+
|
|
162
|
+
j += 1
|
|
163
|
+
end
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Matched characters that came out in a different order on each side.
|
|
168
|
+
# Walking both sides in step, every position where the two disagree is
|
|
169
|
+
# half of a transposition, which is why the caller halves the count.
|
|
170
|
+
sig do
|
|
171
|
+
params(left: T::Array[Integer], right: T::Array[Integer],
|
|
172
|
+
left_matched: T::Array[T::Boolean], right_matched: T::Array[T::Boolean])
|
|
173
|
+
.returns(Integer)
|
|
174
|
+
.checked(:tests)
|
|
175
|
+
end
|
|
176
|
+
def transpositions(left, right, left_matched, right_matched)
|
|
177
|
+
count = 0
|
|
178
|
+
k = 0
|
|
179
|
+
left.each_with_index do |code, i|
|
|
180
|
+
next unless left_matched[i]
|
|
181
|
+
|
|
182
|
+
k += 1 until right_matched.fetch(k)
|
|
183
|
+
count += 1 unless code == right.fetch(k)
|
|
184
|
+
k += 1
|
|
185
|
+
end
|
|
186
|
+
count
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The prefix bonus.
|
|
190
|
+
sig do
|
|
191
|
+
params(left: T::Array[Integer], right: T::Array[Integer], jaro: Float).returns(Float).checked(:tests)
|
|
192
|
+
end
|
|
193
|
+
def winkler(left, right, jaro)
|
|
194
|
+
return jaro if jaro < BOOST_THRESHOLD
|
|
195
|
+
|
|
196
|
+
jaro + (prefix_length(left, right) * PREFIX_SCALE * (1.0 - jaro))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sig { params(left: T::Array[Integer], right: T::Array[Integer]).returns(Integer).checked(:tests) }
|
|
200
|
+
def prefix_length(left, right)
|
|
201
|
+
limit = [MAX_PREFIX, left.size, right.size].min
|
|
202
|
+
length = 0
|
|
203
|
+
length += 1 while length < limit && left.fetch(length) == right.fetch(length)
|
|
204
|
+
length
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private_class_method :jaro_score, :match, :partner, :transpositions, :winkler, :prefix_length
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Similarity
|
|
8
|
+
# Levenshtein edit distance, and the similarity derived from it, 0..1.
|
|
9
|
+
#
|
|
10
|
+
# ActiveSanction::Similarity::Levenshtein.distance("kitten", "sitting") # => 3
|
|
11
|
+
# ActiveSanction::Similarity::Levenshtein.call("kitten", "sitting") # => 0.5714
|
|
12
|
+
#
|
|
13
|
+
# ### Why this one, next to Jaro-Winkler
|
|
14
|
+
#
|
|
15
|
+
# It answers a different question. Jaro-Winkler asks how many characters
|
|
16
|
+
# two names share and how far out of order they are, and it is generous:
|
|
17
|
+
# it has no way to charge for a name that is simply longer than the other,
|
|
18
|
+
# and its prefix bonus is deliberately biased toward the front. Edit
|
|
19
|
+
# distance counts what it would actually take to turn one string into the
|
|
20
|
+
# other, which is the thing that stays honest when a name gains a whole
|
|
21
|
+
# word.
|
|
22
|
+
#
|
|
23
|
+
# JaroWinkler.call("gazprom", "gazprombank") # => 0.9273
|
|
24
|
+
# Levenshtein.call("gazprom", "gazprombank") # => 0.6364
|
|
25
|
+
#
|
|
26
|
+
# Two different companies, and the scorer (#32) blends the two numbers
|
|
27
|
+
# precisely so that neither algorithm's blind spot decides a hit on its
|
|
28
|
+
# own.
|
|
29
|
+
#
|
|
30
|
+
# ### Normalized by the longer string
|
|
31
|
+
#
|
|
32
|
+
# `1 - distance / longer.length`, so the result is comparable across name
|
|
33
|
+
# lengths and against Jaro-Winkler. Dividing by the longer of the two is
|
|
34
|
+
# what makes the measure symmetric and keeps it in 0..1: the distance can
|
|
35
|
+
# never exceed the longer length, and it equals it exactly when the two
|
|
36
|
+
# share nothing.
|
|
37
|
+
#
|
|
38
|
+
# ### What else runs on it
|
|
39
|
+
#
|
|
40
|
+
# Both token ratios. TokenSort is one call to this on a pair of names
|
|
41
|
+
# whose tokens have been put in alphabetical order, and TokenSet is three
|
|
42
|
+
# on a pair that has been split into what they share and what they do not,
|
|
43
|
+
# so half of stage 3 is this file with the strings rearranged first. See
|
|
44
|
+
# TokenSort for why the rearranging is not done on top of Jaro-Winkler.
|
|
45
|
+
module Levenshtein
|
|
46
|
+
extend T::Sig
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# The similarity of two already-folded strings -- see Similarity for
|
|
51
|
+
# what `threshold:` does and what it promises.
|
|
52
|
+
sig { params(left: String, right: String, threshold: Numeric).returns(Float).checked(:tests) }
|
|
53
|
+
def call(left, right, threshold: 0.0)
|
|
54
|
+
cutoff = Similarity.threshold!(threshold)
|
|
55
|
+
return 1.0 if left == right
|
|
56
|
+
|
|
57
|
+
left_codes = Similarity.codepoints(left)
|
|
58
|
+
right_codes = Similarity.codepoints(right)
|
|
59
|
+
longer = [left_codes.size, right_codes.size].max
|
|
60
|
+
return 0.0 if ceiling(left_codes.size, right_codes.size) < cutoff
|
|
61
|
+
|
|
62
|
+
# An edit budget rather than a score: within the budget the score is
|
|
63
|
+
# at or above the threshold, and the rows stop the moment a row's
|
|
64
|
+
# smallest value passes it.
|
|
65
|
+
distance = rows(left_codes, right_codes, ((1.0 - cutoff) * longer).floor)
|
|
66
|
+
return 0.0 if distance.nil?
|
|
67
|
+
|
|
68
|
+
score = 1.0 - distance.fdiv(longer)
|
|
69
|
+
score < cutoff ? 0.0 : score
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The number of single-character insertions, deletions and substitutions
|
|
73
|
+
# that turn one string into the other. Exact, and public because it is
|
|
74
|
+
# the quantity people know: a caller who wants "within two typos"
|
|
75
|
+
# already knows what to compare against, and asking that of a 0..1 score
|
|
76
|
+
# means multiplying by a length.
|
|
77
|
+
sig { params(left: String, right: String).returns(Integer).checked(:tests) }
|
|
78
|
+
def distance(left, right)
|
|
79
|
+
return 0 if left == right
|
|
80
|
+
|
|
81
|
+
left_codes = Similarity.codepoints(left)
|
|
82
|
+
right_codes = Similarity.codepoints(right)
|
|
83
|
+
|
|
84
|
+
# No cell can exceed the longer length, so a budget of it never fires
|
|
85
|
+
# and the walk is exhaustive.
|
|
86
|
+
T.must(rows(left_codes, right_codes, [left_codes.size, right_codes.size].max))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The highest score two strings of these lengths can reach, whatever
|
|
90
|
+
# they contain. This is the early exit: a pair whose ceiling is under
|
|
91
|
+
# the caller's threshold is rejected without either string being looked
|
|
92
|
+
# at, and without the matrix.
|
|
93
|
+
#
|
|
94
|
+
# Turning the shorter string into the longer one costs at least the
|
|
95
|
+
# difference in their lengths -- every missing character is an
|
|
96
|
+
# insertion, however well the rest lines up -- so the score can be no
|
|
97
|
+
# better than `shorter / longer`. Unlike Jaro-Winkler's ceiling this one
|
|
98
|
+
# is tight, and it bites: at a 0.85 threshold it rejects every pair
|
|
99
|
+
# whose lengths differ by more than 15%, which on these lists is most of
|
|
100
|
+
# the corpus for a given query.
|
|
101
|
+
sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
|
|
102
|
+
def ceiling(left_length, right_length)
|
|
103
|
+
shorter = [left_length, right_length].min
|
|
104
|
+
longer = [left_length, right_length].max
|
|
105
|
+
return shorter == longer ? 1.0 : 0.0 if shorter.zero?
|
|
106
|
+
|
|
107
|
+
shorter.fdiv(longer)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The matrix, one row at a time, or nil once no row can lead to a
|
|
111
|
+
# distance within `max`.
|
|
112
|
+
#
|
|
113
|
+
# Two rows rather than the full grid: a cell depends on the one above
|
|
114
|
+
# it, the one to its left, and the one diagonally above-left, so nothing
|
|
115
|
+
# older than the previous row is ever read again. The full matrix for a
|
|
116
|
+
# pair of 40-character names is 1,681 cells held for no reason, and the
|
|
117
|
+
# scorer runs this a few hundred times per query.
|
|
118
|
+
#
|
|
119
|
+
# The cutoff is what makes `threshold:` worth more than the length check
|
|
120
|
+
# in `ceiling`: row minima never decrease as the walk descends, so once
|
|
121
|
+
# a row's smallest value is past the budget, no later row and no final
|
|
122
|
+
# cell can come back under it.
|
|
123
|
+
sig do
|
|
124
|
+
params(left: T::Array[Integer], right: T::Array[Integer], max: Integer)
|
|
125
|
+
.returns(T.nilable(Integer))
|
|
126
|
+
.checked(:tests)
|
|
127
|
+
end
|
|
128
|
+
def rows(left, right, max)
|
|
129
|
+
previous = (0..right.size).to_a
|
|
130
|
+
current = Array.new(right.size + 1, 0)
|
|
131
|
+
left.each_with_index do |code, i|
|
|
132
|
+
current[0] = i + 1
|
|
133
|
+
return nil if fill(code, right, previous, current) > max
|
|
134
|
+
|
|
135
|
+
previous, current = current, previous
|
|
136
|
+
end
|
|
137
|
+
previous.fetch(right.size)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# One row, and the smallest value in it. Three of the four numbers a
|
|
141
|
+
# cell needs are already in hand -- the substitution and deletion costs
|
|
142
|
+
# come from the same two positions of the previous row, and the
|
|
143
|
+
# insertion cost is the cell just written -- so only one read of each
|
|
144
|
+
# row and one of the string happen per cell.
|
|
145
|
+
#
|
|
146
|
+
# `fetch` rather than `[]` for those: this is the innermost loop in the
|
|
147
|
+
# library, and Sorbet types `Array#[]` as nilable, so every read through
|
|
148
|
+
# it would carry a `T.must`, which is a Ruby-level method call per cell.
|
|
149
|
+
sig do
|
|
150
|
+
params(code: Integer, right: T::Array[Integer], previous: T::Array[Integer], current: T::Array[Integer])
|
|
151
|
+
.returns(Integer)
|
|
152
|
+
.checked(:tests)
|
|
153
|
+
end
|
|
154
|
+
def fill(code, right, previous, current)
|
|
155
|
+
# The cell to the left is the one just written and the cell
|
|
156
|
+
# diagonally above-left is the one that was above, so both are carried
|
|
157
|
+
# in locals rather than read back out of the rows.
|
|
158
|
+
left_cell = current.fetch(0)
|
|
159
|
+
smallest = left_cell
|
|
160
|
+
diagonal = previous.fetch(0)
|
|
161
|
+
width = right.size
|
|
162
|
+
j = 0
|
|
163
|
+
while j < width
|
|
164
|
+
above = previous.fetch(j + 1)
|
|
165
|
+
value = diagonal + (right.fetch(j) == code ? 0 : 1) # substitution
|
|
166
|
+
value = above + 1 if above < value # deletion
|
|
167
|
+
value = left_cell + 1 if left_cell < value # insertion
|
|
168
|
+
diagonal = above
|
|
169
|
+
left_cell = current[j + 1] = value
|
|
170
|
+
smallest = value if value < smallest
|
|
171
|
+
j += 1
|
|
172
|
+
end
|
|
173
|
+
smallest
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private_class_method :rows, :fill
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|