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,279 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/phonetics"
|
|
7
|
+
require "active_sanction/similarity"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
module Scorer
|
|
11
|
+
# How close two folded names are, on the 0..100 scale a screening decision
|
|
12
|
+
# is made on.
|
|
13
|
+
#
|
|
14
|
+
# left = ActiveSanction::Normalizer.call("ABBAS, Abu")
|
|
15
|
+
# right = ActiveSanction::Normalizer.call("Abu Abbas")
|
|
16
|
+
#
|
|
17
|
+
# ActiveSanction::Scorer::NameScore.call(left, right) # => 90.4
|
|
18
|
+
# ActiveSanction::Scorer::NameScore.ratios(left, right)
|
|
19
|
+
# # => { jaro_winkler: 0.805, levenshtein: 0.333, token_sort: 1.0,
|
|
20
|
+
# # token_set: 1.0, phonetic: 1.0 }
|
|
21
|
+
#
|
|
22
|
+
# Stage 4a. Everything in Similarity answers one question about a pair of
|
|
23
|
+
# names and answers it well; this is the one place in the library that
|
|
24
|
+
# decides what those four answers are worth together, and Weights is why
|
|
25
|
+
# each is worth what it is.
|
|
26
|
+
#
|
|
27
|
+
# ### The blend is a weighted mean, and that is a choice
|
|
28
|
+
#
|
|
29
|
+
# The obvious alternative is a weighted maximum -- take the best evidence
|
|
30
|
+
# any of the four found, discounted by how tolerant that comparison is --
|
|
31
|
+
# which is what the well-known Python ratio does. It is rejected here for
|
|
32
|
+
# one reason: `token_set` returns 1.0 whenever one name's words are a
|
|
33
|
+
# subset of the other's, so a maximum would score the query `Mohammed`
|
|
34
|
+
# against `MOHAMMED AL-ZAWAHIRI` in the nineties. On a corpus where a
|
|
35
|
+
# quarter of the individuals share a handful of given names, that is not a
|
|
36
|
+
# tolerance, it is an alert queue nobody can work through.
|
|
37
|
+
#
|
|
38
|
+
# A mean makes the four disagree in public instead. The same pair comes
|
|
39
|
+
# out in the high seventies -- still high, because the caller's whole query
|
|
40
|
+
# really is on the record, and that is the honest answer -- and what pulls
|
|
41
|
+
# it apart from a real match is not the name at all. It is the date of
|
|
42
|
+
# birth and the passport number, which is exactly the argument the issue
|
|
43
|
+
# this implements makes: name alone produces enormous false-positive
|
|
44
|
+
# volume on common names, and identifiers are the corrective.
|
|
45
|
+
#
|
|
46
|
+
# ### The phonetic share
|
|
47
|
+
#
|
|
48
|
+
# Double Metaphone answers with a key rather than a number, so it is
|
|
49
|
+
# turned into one the only way that respects what a key means: the
|
|
50
|
+
# fraction of the shorter name's tokens that have a token on the other
|
|
51
|
+
# side sounding like them. `QADHAFI, Muammar` against `Muammar Gaddafi` is
|
|
52
|
+
# 1.0 -- both tokens have a partner -- and one token in three agreeing is
|
|
53
|
+
# 0.33.
|
|
54
|
+
#
|
|
55
|
+
# Per token rather than over the whole name, because that is how the keys
|
|
56
|
+
# are built (see Phonetics: a token handed over on its own is what the
|
|
57
|
+
# index and this both key on) and because a name is rearranged as often as
|
|
58
|
+
# it is respelled.
|
|
59
|
+
#
|
|
60
|
+
# ### What this stage cannot do, and what covers it
|
|
61
|
+
#
|
|
62
|
+
# One name transliterated two different ways scores poorly here, and
|
|
63
|
+
# raising the phonetic share does not fix it. `QADHAFI, Muammar` against
|
|
64
|
+
# `Muammar Gaddafi` comes out at 58.8: the token ratios see two words with
|
|
65
|
+
# two letters different and one word matching, the character algorithms
|
|
66
|
+
# see less than that, and the phonetic share is one of five. Pushing that
|
|
67
|
+
# share to 0.15 moves the pair to 66.8 -- still under any threshold worth
|
|
68
|
+
# setting, while lifting every common-name near-miss by the same few
|
|
69
|
+
# points. It buys nothing and costs precision, so it is not done.
|
|
70
|
+
#
|
|
71
|
+
# What actually covers the case is upstream and is the reason this stage
|
|
72
|
+
# is built the way it is. These lists publish the variants themselves:
|
|
73
|
+
# OFAC's Qadhafi record carries `QADHAFI`, `QADAFI`, `GADAFI`, `KADAFI`
|
|
74
|
+
# and half a dozen more as aliases, because a sanctions list whose
|
|
75
|
+
# spelling had to be guessed would not work either. The index (#31) keys
|
|
76
|
+
# on Double Metaphone so that a query for one spelling retrieves a record
|
|
77
|
+
# filed under another, and the scorer takes the *maximum over an entity's
|
|
78
|
+
# names* -- so the query meets the alias it is actually a spelling of and
|
|
79
|
+
# is scored against that one instead. `Muammar Gaddafi` against the
|
|
80
|
+
# `GADDAFI, Muammar` alias is 84.8, where the same query against the
|
|
81
|
+
# `QADHAFI, Muammar` primary name is 58.8. See Scorer.
|
|
82
|
+
#
|
|
83
|
+
# The residue is a record that carries one spelling and one only, queried
|
|
84
|
+
# with a different one. That is a real recall limitation, it is stated
|
|
85
|
+
# rather than papered over, and the honest mitigation is the identifier
|
|
86
|
+
# fields rather than a bigger number in Weights.
|
|
87
|
+
#
|
|
88
|
+
# ### Cost, and why `threshold:` is most of it
|
|
89
|
+
#
|
|
90
|
+
# This runs a few hundred times per screening call and is very nearly all
|
|
91
|
+
# of what one costs -- the identifier adjustments, the fold and the
|
|
92
|
+
# explanation together are under 2% of it. Unthresholded, the five shares
|
|
93
|
+
# come to about 280 us per pair of names without a JIT and 120 with one,
|
|
94
|
+
# which puts a 200-candidate query at roughly 105 ms and 46 ms. Neither of
|
|
95
|
+
# those is a screening call.
|
|
96
|
+
#
|
|
97
|
+
# `threshold:` is what makes it one, and it keeps exactly the promise
|
|
98
|
+
# Similarity's does: a score at or above the threshold is the same Float
|
|
99
|
+
# the same call without one returns, and anything below is reported as 0.0
|
|
100
|
+
# rather than computed. Two mechanisms, both exact:
|
|
101
|
+
#
|
|
102
|
+
# **The shares are measured one at a time and the sum is bounded as they
|
|
103
|
+
# go.** Everything still unmeasured is worth at most its own weight, so
|
|
104
|
+
# `total + remaining` is the highest this pair can still reach; when that
|
|
105
|
+
# falls under the cutoff, the rest is not measured. On a candidate that
|
|
106
|
+
# was never going to clear, that is usually two of the five.
|
|
107
|
+
#
|
|
108
|
+
# **What is measured is measured with a threshold of its own.** Given the
|
|
109
|
+
# weights left to come, the least this share could be worth and still
|
|
110
|
+
# leave the pair reachable is arithmetic, and it is handed down as the
|
|
111
|
+
# algorithm's own `threshold:` -- where Levenshtein turns it into an edit
|
|
112
|
+
# budget and stops its rows early, and Jaro-Winkler rejects on length
|
|
113
|
+
# before looking at a character.
|
|
114
|
+
#
|
|
115
|
+
# Together, on a 200-candidate query against a full-size corpus:
|
|
116
|
+
#
|
|
117
|
+
# threshold no jit yjit results
|
|
118
|
+
# 0 105.6 ms 46.3 ms 184.0
|
|
119
|
+
# 50 85.6 ms 37.2 ms 118.2
|
|
120
|
+
# 75 37.5 ms 16.3 ms 31.4
|
|
121
|
+
# 85 24.0 ms 10.5 ms 10.6
|
|
122
|
+
#
|
|
123
|
+
# `rake benchmark:scorer` prints that sweep and is how to take it again on
|
|
124
|
+
# another machine. The scores that come back are unchanged, which is what
|
|
125
|
+
# the benchmark checks on every run and what the suite holds this to --
|
|
126
|
+
# the exits are bounds on what a pair can reach, never approximations of
|
|
127
|
+
# what it did reach.
|
|
128
|
+
#
|
|
129
|
+
# The threshold is therefore not an optional refinement for the caller in
|
|
130
|
+
# front of this. A matcher that screens without one spends three times the
|
|
131
|
+
# budget computing exact scores for candidates it is about to discard.
|
|
132
|
+
#
|
|
133
|
+
# Two smaller things keep the unthresholded path honest as well. Tokens
|
|
134
|
+
# are handed to the token ratios as the arrays a Form already holds, so
|
|
135
|
+
# nothing is split per comparison; and the phonetic pass skips any token
|
|
136
|
+
# that appears on both sides verbatim, which on a real match is most of
|
|
137
|
+
# them, so Double Metaphone runs on the tokens that actually differ.
|
|
138
|
+
#
|
|
139
|
+
#
|
|
140
|
+
# @api private
|
|
141
|
+
module NameScore
|
|
142
|
+
extend T::Sig
|
|
143
|
+
extend T::Helpers
|
|
144
|
+
|
|
145
|
+
# Called as `NameScore.measure`, which is where `raise` comes from.
|
|
146
|
+
requires_ancestor { Kernel }
|
|
147
|
+
|
|
148
|
+
# The scale a screening score is read and thresholded on. Similarity
|
|
149
|
+
# works in 0..1 and never rounds; the conversion happens once, here.
|
|
150
|
+
SCALE = T.let(100.0, Float)
|
|
151
|
+
|
|
152
|
+
# The order the shares are measured in, which is the only reason this
|
|
153
|
+
# differs from the order Weights lists them in. It is measured rather
|
|
154
|
+
# than argued -- `rake benchmark:scorer` is what produced it -- and what
|
|
155
|
+
# it optimizes is not cost per share but how quickly the bound tightens
|
|
156
|
+
# for what each share costs.
|
|
157
|
+
#
|
|
158
|
+
# **Jaro-Winkler first**, at about 18 us the cheapest of the five, and
|
|
159
|
+
# it resolves 0.15 of the weight before anything expensive runs.
|
|
160
|
+
#
|
|
161
|
+
# **The token set ratio second**, though at about 80 us it is the
|
|
162
|
+
# dearest of the four that can be thresholded: it carries 0.45 of the
|
|
163
|
+
# weight on its own, which is most of what the bound needs, and it is
|
|
164
|
+
# the measure that separates a candidate worth finishing from one that
|
|
165
|
+
# is not. Running it early is what lets a poor candidate be abandoned
|
|
166
|
+
# two measures in rather than four.
|
|
167
|
+
#
|
|
168
|
+
# **The phonetic pass last, always.** It is the smallest share at 0.05
|
|
169
|
+
# and it is also, at about 85 us, the most expensive thing here -- the
|
|
170
|
+
# one measure that cannot be given a threshold of its own, because
|
|
171
|
+
# Double Metaphone answers with a key and has no early exit to offer.
|
|
172
|
+
# Its cost is paid in full whenever it is paid at all, so last is where
|
|
173
|
+
# it is paid least often: by then the bound is within 0.05 of settled,
|
|
174
|
+
# and almost every candidate has already been decided.
|
|
175
|
+
ORDER = T.let(%i[jaro_winkler token_set token_sort levenshtein phonetic].freeze, T::Array[Symbol])
|
|
176
|
+
|
|
177
|
+
module_function
|
|
178
|
+
|
|
179
|
+
# The blended similarity of two folded names, 0..100 and unrounded --
|
|
180
|
+
# Reason rounds once, where the number becomes something a person reads.
|
|
181
|
+
#
|
|
182
|
+
# `threshold:` is on the same 0..100 scale as the answer, and it is an
|
|
183
|
+
# optimization rather than a filter: a pair that cannot reach it comes
|
|
184
|
+
# back 0.0 instead of being finished. See the note on cost above.
|
|
185
|
+
sig do
|
|
186
|
+
params(left: Normalizer::Form, right: Normalizer::Form, weights: Weights, threshold: Numeric)
|
|
187
|
+
.returns(Float).checked(:tests)
|
|
188
|
+
end
|
|
189
|
+
def call(left, right, weights = Weights.default, threshold: 0.0)
|
|
190
|
+
cutoff = (threshold.to_f / SCALE).clamp(0.0, 1.0)
|
|
191
|
+
total = 0.0
|
|
192
|
+
remaining = 1.0
|
|
193
|
+
ORDER.each do |share|
|
|
194
|
+
weight = weights.fetch(share)
|
|
195
|
+
return 0.0 if total + remaining < cutoff
|
|
196
|
+
|
|
197
|
+
remaining -= weight
|
|
198
|
+
total += weight * measure(share, left, right, floor(cutoff, total, remaining, weight))
|
|
199
|
+
end
|
|
200
|
+
total < cutoff ? 0.0 : SCALE * total
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The least this share can be worth while leaving the pair able to reach
|
|
204
|
+
# the cutoff, given everything already measured and everything still to
|
|
205
|
+
# come at its best. Zero when the pair can reach the cutoff whatever
|
|
206
|
+
# this share says, which is what a threshold of nothing always produces.
|
|
207
|
+
sig do
|
|
208
|
+
params(cutoff: Float, total: Float, remaining: Float, weight: Float).returns(Float).checked(:tests)
|
|
209
|
+
end
|
|
210
|
+
def floor(cutoff, total, remaining, weight)
|
|
211
|
+
return 0.0 unless weight.positive?
|
|
212
|
+
|
|
213
|
+
((cutoff - total - remaining) / weight).clamp(0.0, 1.0)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# One share, with its own early exit. A share a host has weighted to
|
|
217
|
+
# zero is not measured at all.
|
|
218
|
+
sig do
|
|
219
|
+
params(share: Symbol, left: Normalizer::Form, right: Normalizer::Form, threshold: Float)
|
|
220
|
+
.returns(Float).checked(:tests)
|
|
221
|
+
end
|
|
222
|
+
def measure(share, left, right, threshold = 0.0)
|
|
223
|
+
case share
|
|
224
|
+
when :jaro_winkler then Similarity::JaroWinkler.call(left.value, right.value, threshold: threshold)
|
|
225
|
+
when :levenshtein then Similarity::Levenshtein.call(left.value, right.value, threshold: threshold)
|
|
226
|
+
when :token_sort then Similarity::TokenSort.call(left.tokens, right.tokens, threshold: threshold)
|
|
227
|
+
when :token_set then Similarity::TokenSet.call(left.tokens, right.tokens, threshold: threshold)
|
|
228
|
+
when :phonetic then phonetic(left.tokens, right.tokens)
|
|
229
|
+
else raise InvalidArgument, "unknown name share #{share.inspect}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# What each of the five actually said, which is the whole of the
|
|
234
|
+
# difference between a score and a number.
|
|
235
|
+
#
|
|
236
|
+
# Public for the reason `TokenSort.sorted` and `TokenSet.strings` are
|
|
237
|
+
# public: a hit a compliance user cannot account for is a hit they
|
|
238
|
+
# cannot clear, and an analyst asking why two names scored what they did
|
|
239
|
+
# gets the answer by printing this beside the weights.
|
|
240
|
+
sig do
|
|
241
|
+
params(left: Normalizer::Form, right: Normalizer::Form).returns(T::Hash[Symbol, Float]).checked(:tests)
|
|
242
|
+
end
|
|
243
|
+
def ratios(left, right)
|
|
244
|
+
Weights::NAME_SHARES.to_h { |share| [share, measure(share, left, right)] }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The fraction of the shorter name's tokens that sound like a token of
|
|
248
|
+
# the longer one.
|
|
249
|
+
#
|
|
250
|
+
# The shorter side is the denominator on purpose: a query of two tokens
|
|
251
|
+
# against a record of four should not be capped at 0.5 for the two the
|
|
252
|
+
# record has and the query does not. That is what `token_set` is already
|
|
253
|
+
# measuring, in a share of its own.
|
|
254
|
+
sig { params(left: T::Array[String], right: T::Array[String]).returns(Float).checked(:tests) }
|
|
255
|
+
def phonetic(left, right)
|
|
256
|
+
return 0.0 if left.empty? || right.empty?
|
|
257
|
+
|
|
258
|
+
shorter, longer = left.size <= right.size ? [left, right] : [right, left]
|
|
259
|
+
keys = T.let(nil, T.nilable(T::Array[String]))
|
|
260
|
+
agreed = shorter.count do |token|
|
|
261
|
+
# Identical tokens sound identical, and skipping them is what keeps
|
|
262
|
+
# the phonetic pass off the hot path of a real match.
|
|
263
|
+
next true if longer.include?(token)
|
|
264
|
+
|
|
265
|
+
keys ||= sounds(longer)
|
|
266
|
+
Phonetics::DoubleMetaphone.call(token).intersect?(keys)
|
|
267
|
+
end
|
|
268
|
+
agreed.fdiv(shorter.size)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Every Double Metaphone key of every token, primary and alternate --
|
|
272
|
+
# see Phonetics for why the alternate is not optional. Flattened across
|
|
273
|
+
# the tokens because the question here is whether *any* token of the
|
|
274
|
+
# longer name sounds like the one being tested.
|
|
275
|
+
sig { params(tokens: T::Array[String]).returns(T::Array[String]).checked(:tests) }
|
|
276
|
+
def sounds(tokens) = tokens.flat_map { |token| Phonetics::DoubleMetaphone.call(token) }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Scorer
|
|
8
|
+
# One line of the answer to "why did this score 87?".
|
|
9
|
+
#
|
|
10
|
+
# ActiveSanction::Scorer::Reason.new(
|
|
11
|
+
# factor: :dob, detail: "year 1948 matches", contribution: 6.0
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# ### The explanation adds up
|
|
15
|
+
#
|
|
16
|
+
# A Result's `explanation` is a list of these, and their contributions sum
|
|
17
|
+
# to exactly the score. That is the whole design: the name blend produces
|
|
18
|
+
# the first one and every secondary identifier appends a signed delta, so
|
|
19
|
+
# a reviewer reading the list downwards arrives at the number on the
|
|
20
|
+
# report rather than at something near it.
|
|
21
|
+
#
|
|
22
|
+
# It is a property the suite holds rather than a coincidence of the
|
|
23
|
+
# arithmetic. Contributions are rounded once, here, and the score is the
|
|
24
|
+
# sum of the rounded values -- not the rounded sum, which is how a total
|
|
25
|
+
# ends up one tenth away from the figures printed beside it. Where
|
|
26
|
+
# clamping moves the total off the sum, that correction is itself a reason
|
|
27
|
+
# (`:clamp`), because a score that silently stopped at 100 is a score
|
|
28
|
+
# whose explanation no longer explains it.
|
|
29
|
+
#
|
|
30
|
+
# ### Why this is a required output and not a debugging aid
|
|
31
|
+
#
|
|
32
|
+
# A compliance officer has to defend a screening decision to an examiner,
|
|
33
|
+
# and "the library said 87" is not a defence. Both directions matter: an
|
|
34
|
+
# alert that cannot be accounted for cannot be cleared, and a *clearance*
|
|
35
|
+
# that cannot be accounted for is the one an examiner asks about. So every
|
|
36
|
+
# adjustment the scorer makes writes one of these, including the ones that
|
|
37
|
+
# lower a score.
|
|
38
|
+
#
|
|
39
|
+
# Instances are frozen on construction and compare by value.
|
|
40
|
+
class Reason
|
|
41
|
+
extend T::Sig
|
|
42
|
+
|
|
43
|
+
# `:name` is the blended name similarity and is always first. The rest
|
|
44
|
+
# are the secondary-identifier adjustments, in the order Adjustments
|
|
45
|
+
# applies them, and `:clamp` is the correction described above.
|
|
46
|
+
#
|
|
47
|
+
# Closed, so a typo is caught where the reason is built rather than
|
|
48
|
+
# reaching a report as a factor nothing renders.
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
FACTORS = T.let(%i[name alias_quality identifier dob nationality clamp].freeze, T::Array[Symbol])
|
|
52
|
+
|
|
53
|
+
# One decimal place, which is the precision a screening score is read at
|
|
54
|
+
# -- see the note on adding up above.
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
PRECISION = T.let(1, Integer)
|
|
58
|
+
|
|
59
|
+
# @api private
|
|
60
|
+
MEMBERS = T.let(%i[factor detail contribution].freeze, T::Array[Symbol])
|
|
61
|
+
|
|
62
|
+
sig { returns(Symbol) }
|
|
63
|
+
attr_reader :factor
|
|
64
|
+
|
|
65
|
+
# Written for a person, and it names what was compared rather than which
|
|
66
|
+
# rule fired: "year 1948 matches", not "dob_overlap". A reviewer reading
|
|
67
|
+
# it should not need this library's vocabulary.
|
|
68
|
+
sig { returns(String) }
|
|
69
|
+
attr_reader :detail
|
|
70
|
+
|
|
71
|
+
# Signed, in the same 0..100 units as the score. Positive raises the
|
|
72
|
+
# score and negative lowers it.
|
|
73
|
+
sig { returns(Float) }
|
|
74
|
+
attr_reader :contribution
|
|
75
|
+
|
|
76
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
77
|
+
def self.from_h(hash)
|
|
78
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
79
|
+
unknown = attributes.keys - MEMBERS
|
|
80
|
+
raise InvalidArgument, "unknown Reason attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
81
|
+
|
|
82
|
+
T.unsafe(self).new(**attributes)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(factor: T.untyped, detail: T.untyped, contribution: T.untyped).void }
|
|
86
|
+
def initialize(factor:, detail:, contribution:)
|
|
87
|
+
@factor = T.let(factor!(factor), Symbol)
|
|
88
|
+
@detail = T.let(detail!(detail), String)
|
|
89
|
+
@contribution = T.let(Float(contribution).round(PRECISION).to_f, Float)
|
|
90
|
+
freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { returns(T::Boolean) }
|
|
94
|
+
def penalty? = contribution.negative?
|
|
95
|
+
|
|
96
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
97
|
+
def to_h = { factor: factor, detail: detail, contribution: contribution }
|
|
98
|
+
|
|
99
|
+
# The line a report prints: "+6.0 dob: year 1948 matches".
|
|
100
|
+
sig { returns(String) }
|
|
101
|
+
def to_s = format("%+.#{PRECISION}f %s: %s", contribution, factor, detail)
|
|
102
|
+
|
|
103
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
104
|
+
def ==(other)
|
|
105
|
+
return false unless other.instance_of?(self.class)
|
|
106
|
+
|
|
107
|
+
to_h == other.to_h
|
|
108
|
+
end
|
|
109
|
+
alias eql? ==
|
|
110
|
+
|
|
111
|
+
sig { returns(Integer) }
|
|
112
|
+
def hash = [self.class, to_h].hash
|
|
113
|
+
|
|
114
|
+
sig { returns(String) }
|
|
115
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
120
|
+
def factor!(value)
|
|
121
|
+
symbol = value.to_s.to_sym
|
|
122
|
+
return symbol if FACTORS.include?(symbol)
|
|
123
|
+
|
|
124
|
+
raise InvalidArgument, "unknown factor #{symbol.inspect}, expected one of #{FACTORS.join(", ")}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(value: T.untyped).returns(String) }
|
|
128
|
+
def detail!(value)
|
|
129
|
+
string = value.to_s.strip
|
|
130
|
+
raise InvalidArgument, "detail is required -- a reason nobody can read is not a reason" if string.empty?
|
|
131
|
+
|
|
132
|
+
-string
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/scorer/reason"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Scorer
|
|
10
|
+
# One entity scored against one subject, and the account of how.
|
|
11
|
+
#
|
|
12
|
+
# result = ActiveSanction::Scorer.call(subject, entity)
|
|
13
|
+
#
|
|
14
|
+
# result.score # => 87.4
|
|
15
|
+
# result.entity.id # => "ofac_sdn:2674"
|
|
16
|
+
# result.name.value # => "AERO-CARIBBEAN"
|
|
17
|
+
# result.explanation.map(&:to_s)
|
|
18
|
+
# # => ["+91.2 name: matched alias 'AERO-CARIBBEAN' (aka)",
|
|
19
|
+
# # "+4.0 dob: date of birth 1948 overlaps listed 1948-12-10",
|
|
20
|
+
# # "-8.0 nationality: query RU vs listed EG"]
|
|
21
|
+
#
|
|
22
|
+
# ### The score is the explanation
|
|
23
|
+
#
|
|
24
|
+
# `score` is not stored beside the reasons, it is the sum of them, rounded
|
|
25
|
+
# once. There is no arithmetic anywhere in this library that can move one
|
|
26
|
+
# without the other, which is the point: a compliance officer has to
|
|
27
|
+
# answer "why did this score 87?" to an examiner, and a number that merely
|
|
28
|
+
# travels alongside a list of reasons is a number that can come apart from
|
|
29
|
+
# them in a later release and be wrong quietly for a year.
|
|
30
|
+
#
|
|
31
|
+
# So the explanation is never empty -- the blended name similarity is
|
|
32
|
+
# always the first reason, even when nothing else was known -- and it
|
|
33
|
+
# always adds up.
|
|
34
|
+
#
|
|
35
|
+
# ### `name` is the specific spelling that produced the score
|
|
36
|
+
#
|
|
37
|
+
# An entity's score is the best of its names, and this is the one that
|
|
38
|
+
# won. It matters more than it looks: OFAC ships more aliases than primary
|
|
39
|
+
# names, so the answer to "what did we match?" is usually an alias, and a
|
|
40
|
+
# report that quoted the primary name instead would be describing a
|
|
41
|
+
# comparison that never happened.
|
|
42
|
+
#
|
|
43
|
+
# `form` is that name folded, which is the string the scorers actually
|
|
44
|
+
# compared. Both are here for the reason Normalizer::Form carries both:
|
|
45
|
+
# the published spelling is what a person reads and the folded one is what
|
|
46
|
+
# a person checks.
|
|
47
|
+
#
|
|
48
|
+
# ### What this is not
|
|
49
|
+
#
|
|
50
|
+
# It is not `MatchResult` (#33). This carries what the scorer knows -- a
|
|
51
|
+
# score, a name, an entity, an explanation -- and nothing about the
|
|
52
|
+
# screening run that produced it. The snapshot checksum, the matcher
|
|
53
|
+
# version, the thresholds and the backend all belong to the public API
|
|
54
|
+
# above this one, which is where an audit record is assembled.
|
|
55
|
+
#
|
|
56
|
+
# Instances are frozen on construction and compare by value.
|
|
57
|
+
class Result
|
|
58
|
+
extend T::Sig
|
|
59
|
+
|
|
60
|
+
sig { returns(Entity) }
|
|
61
|
+
attr_reader :entity
|
|
62
|
+
|
|
63
|
+
# The name that scored highest, as its publisher wrote it.
|
|
64
|
+
sig { returns(Name) }
|
|
65
|
+
attr_reader :name
|
|
66
|
+
|
|
67
|
+
# That name folded -- the string the comparison ran on.
|
|
68
|
+
sig { returns(Normalizer::Form) }
|
|
69
|
+
attr_reader :form
|
|
70
|
+
|
|
71
|
+
# 0..100, one decimal place, and equal to the sum of the explanation.
|
|
72
|
+
sig { returns(Float) }
|
|
73
|
+
attr_reader :score
|
|
74
|
+
|
|
75
|
+
# Never empty. See the class comment.
|
|
76
|
+
sig { returns(T::Array[Reason]) }
|
|
77
|
+
attr_reader :explanation
|
|
78
|
+
|
|
79
|
+
sig do
|
|
80
|
+
params(entity: Entity, name: Name, form: Normalizer::Form, explanation: T::Array[Reason])
|
|
81
|
+
.void.checked(:tests)
|
|
82
|
+
end
|
|
83
|
+
def initialize(entity:, name:, form:, explanation:)
|
|
84
|
+
raise InvalidArgument, "a result needs at least one reason" if explanation.empty?
|
|
85
|
+
|
|
86
|
+
@entity = entity
|
|
87
|
+
@name = name
|
|
88
|
+
@form = form
|
|
89
|
+
@explanation = T.let(explanation.dup.freeze, T::Array[Reason])
|
|
90
|
+
@score = T.let(explanation.sum(&:contribution).round(Reason::PRECISION).to_f, Float)
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The list this entity came from, which every hit has to name.
|
|
95
|
+
sig { returns(Symbol) }
|
|
96
|
+
def source = entity.source
|
|
97
|
+
|
|
98
|
+
# The reasons that lowered the score, which is the half of an
|
|
99
|
+
# explanation a reviewer clearing an alert reads first.
|
|
100
|
+
sig { returns(T::Array[Reason]) }
|
|
101
|
+
def penalties = explanation.select(&:penalty?)
|
|
102
|
+
|
|
103
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
104
|
+
def to_h
|
|
105
|
+
{
|
|
106
|
+
score: score,
|
|
107
|
+
entity_id: entity.id,
|
|
108
|
+
source: source,
|
|
109
|
+
name: name.to_h,
|
|
110
|
+
explanation: explanation.map(&:to_h)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
115
|
+
def ==(other)
|
|
116
|
+
return false unless other.instance_of?(self.class)
|
|
117
|
+
|
|
118
|
+
entity == other.entity && name == other.name && explanation == other.explanation
|
|
119
|
+
end
|
|
120
|
+
alias eql? ==
|
|
121
|
+
|
|
122
|
+
sig { returns(Integer) }
|
|
123
|
+
def hash = [self.class, entity, name, explanation].hash
|
|
124
|
+
|
|
125
|
+
sig { returns(String) }
|
|
126
|
+
def inspect = "#<#{self.class} #{score} #{name.value.inspect} (#{entity.id})>"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|