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,159 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Similarity
|
|
8
|
+
# Token set ratio: compare what two names share against what each of them
|
|
9
|
+
# is, and take the best of it. 0..1.
|
|
10
|
+
#
|
|
11
|
+
# TokenSet.call("putin vladimir vladimirovich", "vladimir putin") # => 1.0
|
|
12
|
+
# TokenSet.call("vladimir putin", "vladimir zhirinovsky") # => 0.5714
|
|
13
|
+
# TokenSet.call("gazprom neft", "gazprombank") # => 0.5833
|
|
14
|
+
#
|
|
15
|
+
# ### The shape it exists for
|
|
16
|
+
#
|
|
17
|
+
# A token one side has and the other does not, which is the ordinary
|
|
18
|
+
# condition of a real query rather than an edge case. Lists carry the full
|
|
19
|
+
# legal name and a query carries what somebody typed: a patronymic that
|
|
20
|
+
# only Russian records bother with, a middle name a payment message
|
|
21
|
+
# dropped, a `bin` chain written out in full on one side and abbreviated on
|
|
22
|
+
# the other. Sorting does nothing for any of it -- TokenSort scores
|
|
23
|
+
# `PUTIN, Vladimir Vladimirovich` against `Vladimir Putin` at 0.5, because
|
|
24
|
+
# half the characters on the longer side have no partner and the edit
|
|
25
|
+
# distance charges for every one of them.
|
|
26
|
+
#
|
|
27
|
+
# ### The three strings
|
|
28
|
+
#
|
|
29
|
+
# Take both names as sets of tokens and split them three ways -- what they
|
|
30
|
+
# share, what only the left has, what only the right has -- then build:
|
|
31
|
+
#
|
|
32
|
+
# t0 the shared tokens, alphabetically
|
|
33
|
+
# t1 t0, then the left's own tokens, alphabetically
|
|
34
|
+
# t2 t0, then the right's own tokens, alphabetically
|
|
35
|
+
#
|
|
36
|
+
# and score the pair as the best of `t0/t1`, `t0/t2` and `t1/t2`. For
|
|
37
|
+
# `putin vladimir vladimirovich` against `vladimir putin` that is:
|
|
38
|
+
#
|
|
39
|
+
# t0 = "putin vladimir"
|
|
40
|
+
# t1 = "putin vladimir vladimirovich"
|
|
41
|
+
# t2 = "putin vladimir"
|
|
42
|
+
#
|
|
43
|
+
# `t0/t2` is a perfect match, and the answer is 1.0. Every one of the three
|
|
44
|
+
# comparisons runs on Levenshtein, for the reason TokenSort gives.
|
|
45
|
+
#
|
|
46
|
+
# The construction is Seatgeek's, from `fuzzywuzzy`'s `token_set_ratio`,
|
|
47
|
+
# and it is kept as published rather than adjusted. A screening score that
|
|
48
|
+
# nobody outside this repository can reproduce is a score that has to be
|
|
49
|
+
# argued from scratch every time an examiner asks about it.
|
|
50
|
+
#
|
|
51
|
+
# ### What that tolerance costs, and who pays it
|
|
52
|
+
#
|
|
53
|
+
# A name whose tokens are all present in the other scores 1.0, however
|
|
54
|
+
# much else the other one says:
|
|
55
|
+
#
|
|
56
|
+
# TokenSet.call("gazprom", "gazprom neft") # => 1.0
|
|
57
|
+
#
|
|
58
|
+
# Those are two different companies, and this ratio cannot tell them
|
|
59
|
+
# apart -- `t0/t2` is a string against itself whenever one side's tokens
|
|
60
|
+
# are a subset of the other's, and no length difference is large enough to
|
|
61
|
+
# change that. The same generosity finds `Vladimir Putin` inside `PUTIN,
|
|
62
|
+
# Vladimir Vladimirovich`, so it is not a bug to be fixed here; it is the
|
|
63
|
+
# single property this algorithm has, and it points in both directions.
|
|
64
|
+
#
|
|
65
|
+
# Recorded rather than lamented, and recorded in the specs as well: what
|
|
66
|
+
# keeps it from deciding a hit is that the scorer (#32) blends four
|
|
67
|
+
# numbers, and the other three all charge for the extra word. Levenshtein
|
|
68
|
+
# scores that pair 0.583 and TokenSort 0.583. A scorer that let this
|
|
69
|
+
# column vote alone would put every subsidiary of every listed parent in
|
|
70
|
+
# front of an analyst at 100.
|
|
71
|
+
#
|
|
72
|
+
# ### Sets, so a repeated token is one token
|
|
73
|
+
#
|
|
74
|
+
# `ALI, Ali Hassan` is `ali ali hassan` folded, and the second `ali` is not
|
|
75
|
+
# a second piece of evidence. Deduplicating is what the name of the
|
|
76
|
+
# algorithm says and what these lists want: a repeated given name is a
|
|
77
|
+
# naming convention, not a stronger signal, and counting it twice would
|
|
78
|
+
# make an entity's score depend on how many times its own name repeats.
|
|
79
|
+
module TokenSet
|
|
80
|
+
extend T::Sig
|
|
81
|
+
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
# The similarity of two already-folded names -- see Similarity for what
|
|
85
|
+
# `threshold:` does and what it promises, and for why either side may be
|
|
86
|
+
# a string or the tokens it splits into.
|
|
87
|
+
#
|
|
88
|
+
# The threshold is passed down to all three comparisons rather than
|
|
89
|
+
# applied to their maximum, which keeps the promise exactly: the largest
|
|
90
|
+
# of three numbers is at or above the cutoff precisely when one of them
|
|
91
|
+
# is, and that one comes back exact.
|
|
92
|
+
sig { params(left: Value, right: Value, threshold: Numeric).returns(Float).checked(:tests) }
|
|
93
|
+
def call(left, right, threshold: 0.0)
|
|
94
|
+
cutoff = Similarity.threshold!(threshold)
|
|
95
|
+
left_tokens = Similarity.tokens(left)
|
|
96
|
+
right_tokens = Similarity.tokens(right)
|
|
97
|
+
# A name with no tokens cannot be scored, and the three strings would
|
|
98
|
+
# all be empty and compare as identical if this fell through. Both
|
|
99
|
+
# empty is the answer the other algorithms give for two empty strings.
|
|
100
|
+
return 1.0 if left_tokens.empty? && right_tokens.empty?
|
|
101
|
+
return 0.0 if left_tokens.empty? || right_tokens.empty?
|
|
102
|
+
|
|
103
|
+
shared, left_full, right_full = strings(left_tokens, right_tokens)
|
|
104
|
+
best(shared, left_full, right_full, cutoff)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The three strings this actually compares, in the order `t0, t1, t2`.
|
|
108
|
+
#
|
|
109
|
+
# Public for the reason TokenSort.sorted is: this is the whole of the
|
|
110
|
+
# difference between what a caller passed and what was scored, and a hit
|
|
111
|
+
# a compliance user cannot account for is a hit they cannot clear.
|
|
112
|
+
#
|
|
113
|
+
# Where the sets are made: `&` and `-` both deduplicate what they keep,
|
|
114
|
+
# apart from the repeats inside a name's own tokens, which `uniq`
|
|
115
|
+
# removes.
|
|
116
|
+
sig { params(left: T::Array[String], right: T::Array[String]).returns([String, String, String]).checked(:tests) }
|
|
117
|
+
def strings(left, right)
|
|
118
|
+
shared = (left & right).sort
|
|
119
|
+
[shared.join(" "),
|
|
120
|
+
(shared + (left - right).uniq.sort).join(" "),
|
|
121
|
+
(shared + (right - left).uniq.sort).join(" ")]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 1.0 for any two non-empty names, which is not much of a bound and is
|
|
125
|
+
# the true one.
|
|
126
|
+
#
|
|
127
|
+
# The other three algorithms can rule a perfect score out from a length
|
|
128
|
+
# difference alone, because a character one name has and the other does
|
|
129
|
+
# not costs something wherever it falls. This one cannot: a subset scores
|
|
130
|
+
# 1.0 at any length, which is the whole of what it is for. A tighter
|
|
131
|
+
# number here would be a wrong one, and wrong in the direction that
|
|
132
|
+
# silently discards true matches -- a caller cannot tell a pair rejected
|
|
133
|
+
# by a ceiling from one that scored badly.
|
|
134
|
+
#
|
|
135
|
+
# So `threshold:` buys this ratio nothing before the comparison and only
|
|
136
|
+
# what the three Levenshtein calls can find inside it. That is a real
|
|
137
|
+
# cost, paid where the tolerance is, and it is the reason the scorer
|
|
138
|
+
# runs the cheap columns first.
|
|
139
|
+
sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
|
|
140
|
+
def ceiling(left_length, right_length)
|
|
141
|
+
return left_length == right_length ? 1.0 : 0.0 if left_length.zero? || right_length.zero?
|
|
142
|
+
|
|
143
|
+
1.0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The best of the three, each computed against the caller's cutoff.
|
|
147
|
+
sig do
|
|
148
|
+
params(shared: String, left_full: String, right_full: String, cutoff: Float).returns(Float).checked(:tests)
|
|
149
|
+
end
|
|
150
|
+
def best(shared, left_full, right_full, cutoff)
|
|
151
|
+
[Levenshtein.call(shared, left_full, threshold: cutoff),
|
|
152
|
+
Levenshtein.call(shared, right_full, threshold: cutoff),
|
|
153
|
+
Levenshtein.call(left_full, right_full, threshold: cutoff)].max
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private_class_method :best
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Similarity
|
|
8
|
+
# Token sort ratio: put both names' tokens in alphabetical order, then
|
|
9
|
+
# compare what comes out. 0..1.
|
|
10
|
+
#
|
|
11
|
+
# ActiveSanction::Similarity::TokenSort.call("abbas abu", "abu abbas") # => 1.0
|
|
12
|
+
# ActiveSanction::Similarity::TokenSort.call("smith john", "john smith") # => 1.0
|
|
13
|
+
# ActiveSanction::Similarity::TokenSort.call("abbas abu", "abbas abd") # => 0.8889
|
|
14
|
+
#
|
|
15
|
+
# ### The shape it exists for
|
|
16
|
+
#
|
|
17
|
+
# Sanctions lists store a personal name inverted and a query almost never
|
|
18
|
+
# is. OFAC publishes `ABBAS, Abu` and `ZAYDAN, Muhammad`; a customer
|
|
19
|
+
# record says `Abu Abbas` and a payment message says `Muhammad Zaydan`.
|
|
20
|
+
# The comma does not survive to be read here -- Normalizer turns
|
|
21
|
+
# punctuation into a space, so both sides arrive as the same tokens in
|
|
22
|
+
# opposite orders -- and character-level comparison is helpless in front of
|
|
23
|
+
# that:
|
|
24
|
+
#
|
|
25
|
+
# JaroWinkler.call("abbas abu", "abu abbas") # => 0.8053
|
|
26
|
+
# Levenshtein.call("abbas abu", "abu abbas") # => 0.3333
|
|
27
|
+
# TokenSort.call("abbas abu", "abu abbas") # => 1.0
|
|
28
|
+
#
|
|
29
|
+
# Those are two names that are not merely similar but identical, scored as
|
|
30
|
+
# a miss at the 85 this industry screens on. Sorting is what makes word
|
|
31
|
+
# order stop mattering, and word order is the difference between how these
|
|
32
|
+
# lists are written and how anybody types.
|
|
33
|
+
#
|
|
34
|
+
# It is worth being plain that this is not clever. Sorting is a blunt
|
|
35
|
+
# instrument that answers one question exactly -- are these the same words
|
|
36
|
+
# in some order -- and the reason it is the right instrument is that the
|
|
37
|
+
# inversion it defeats is a publishing convention rather than a
|
|
38
|
+
# coincidence, applied to essentially every individual on every list.
|
|
39
|
+
#
|
|
40
|
+
# ### What it costs, and why Levenshtein is still here
|
|
41
|
+
#
|
|
42
|
+
# Sorting destroys the information that two names were *already* in the
|
|
43
|
+
# same order, which for a pair that shares its leading tokens is
|
|
44
|
+
# information worth having:
|
|
45
|
+
#
|
|
46
|
+
# Levenshtein.call("kim jong un", "kim yong chol") # => 0.6154
|
|
47
|
+
# TokenSort.call("kim jong un", "kim yong chol") # => 0.4615
|
|
48
|
+
#
|
|
49
|
+
# `jong` and `yong` line up as written and are pulled apart by the sort,
|
|
50
|
+
# which puts `jong` next to `chol` and `un` next to `yong`. So this ratio
|
|
51
|
+
# is an additional question rather than a better one, and the scorer (#32)
|
|
52
|
+
# is where the two answers meet. A pipeline that sorted first and compared
|
|
53
|
+
# once would be strictly worse than one that does neither.
|
|
54
|
+
#
|
|
55
|
+
# ### Why Levenshtein underneath and not Jaro-Winkler
|
|
56
|
+
#
|
|
57
|
+
# Winkler's premise is that people get the beginning of a name right and
|
|
58
|
+
# drift later, which is true of a name as it is written and false of one
|
|
59
|
+
# whose words have just been put in alphabetical order: the front of a
|
|
60
|
+
# sorted string is whichever token happened to sort first, so the prefix
|
|
61
|
+
# bonus would be paying for a property of the alphabet. Sorted `abbas abd`
|
|
62
|
+
# and `abbas abu` share six characters of prefix for no reason anybody
|
|
63
|
+
# typed.
|
|
64
|
+
#
|
|
65
|
+
# Levenshtein also charges honestly for a token the other side does not
|
|
66
|
+
# have, which after sorting is most of what is left to measure. Being
|
|
67
|
+
# generous about that is the other ratio's job, and two generous
|
|
68
|
+
# algorithms stacked on each other is how a token ratio starts saying yes
|
|
69
|
+
# to everything.
|
|
70
|
+
#
|
|
71
|
+
# ### What it does not handle, which is why TokenSet exists
|
|
72
|
+
#
|
|
73
|
+
# A token on one side and not the other. Sorting lines the shared words up
|
|
74
|
+
# but does nothing about the ones with nowhere to go, and the edit
|
|
75
|
+
# distance charges for every character of them:
|
|
76
|
+
#
|
|
77
|
+
# TokenSort.call("putin vladimir vladimirovich", "vladimir putin") # => 0.5
|
|
78
|
+
#
|
|
79
|
+
# Half the name is a patronymic the query did not carry, and half is what
|
|
80
|
+
# this scores. That is the whole of TokenSet's job.
|
|
81
|
+
module TokenSort
|
|
82
|
+
extend T::Sig
|
|
83
|
+
|
|
84
|
+
module_function
|
|
85
|
+
|
|
86
|
+
# The similarity of two already-folded names -- see Similarity for what
|
|
87
|
+
# `threshold:` does and what it promises, and for why either side may be
|
|
88
|
+
# a string or the tokens it splits into.
|
|
89
|
+
sig { params(left: Value, right: Value, threshold: Numeric).returns(Float).checked(:tests) }
|
|
90
|
+
def call(left, right, threshold: 0.0)
|
|
91
|
+
cutoff = Similarity.threshold!(threshold)
|
|
92
|
+
|
|
93
|
+
Levenshtein.call(sorted(left), sorted(right), threshold: cutoff)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The string this actually compares: the name's tokens in alphabetical
|
|
97
|
+
# order, single-spaced.
|
|
98
|
+
#
|
|
99
|
+
# Public because a score nobody can account for is a score nobody can
|
|
100
|
+
# defend, and `sorted` is the whole of the difference between what a
|
|
101
|
+
# caller passed and what was compared. An analyst asking why `kim jong
|
|
102
|
+
# un` scored what it did against `kim yong chol` gets the answer by
|
|
103
|
+
# printing this.
|
|
104
|
+
sig { params(value: Value).returns(String).checked(:tests) }
|
|
105
|
+
def sorted(value) = Similarity.tokens(value).sort.join(" ")
|
|
106
|
+
|
|
107
|
+
# Levenshtein's ceiling, on the lengths of the two folded names, because
|
|
108
|
+
# sorting a folded name's tokens does not change its length -- the same
|
|
109
|
+
# characters and the same single spaces come out in a different order.
|
|
110
|
+
# That equality is the one thing this delegation rests on, and it is the
|
|
111
|
+
# reason the contract says *folded* names: a string carrying double
|
|
112
|
+
# spaces or a leading one is shorter after the sort than the length it
|
|
113
|
+
# reports, and a ceiling computed from the longer length would come in
|
|
114
|
+
# under the real score, which is the one direction a bound may never
|
|
115
|
+
# err in.
|
|
116
|
+
sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
|
|
117
|
+
def ceiling(left_length, right_length) = Levenshtein.ceiling(left_length, right_length)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
# How close two folded names are, as a number between 0 and 1. Stage 3 of
|
|
8
|
+
# the matching pipeline.
|
|
9
|
+
#
|
|
10
|
+
# left = ActiveSanction::Normalizer.call("ABBAS, Abu")
|
|
11
|
+
# right = ActiveSanction::Normalizer.call("Abu Abbas")
|
|
12
|
+
#
|
|
13
|
+
# ActiveSanction::Similarity::JaroWinkler.call(left.value, right.value) # => 0.8053
|
|
14
|
+
# ActiveSanction::Similarity::Levenshtein.call(left.value, right.value) # => 0.3333
|
|
15
|
+
# ActiveSanction::Similarity::TokenSort.call(left.tokens, right.tokens) # => 1.0
|
|
16
|
+
# ActiveSanction::Similarity::TokenSet.call(left.tokens, right.tokens) # => 1.0
|
|
17
|
+
#
|
|
18
|
+
# Four algorithms and nothing else. Two compare characters -- JaroWinkler
|
|
19
|
+
# and Levenshtein, stage 3a -- and two compare tokens -- TokenSort and
|
|
20
|
+
# TokenSet, stage 3b. The token ratios are rearrangements of the same two
|
|
21
|
+
# names with Levenshtein run over the result, so there is still exactly one
|
|
22
|
+
# place in this library that knows how to walk two strings. The scorer (#32)
|
|
23
|
+
# blends all four, and nothing in the library has any other way to ask how
|
|
24
|
+
# close two names are.
|
|
25
|
+
#
|
|
26
|
+
# ### Why four
|
|
27
|
+
#
|
|
28
|
+
# Each of them is generous about something, and their disagreements are the
|
|
29
|
+
# point:
|
|
30
|
+
#
|
|
31
|
+
# JW Lev Sort Set
|
|
32
|
+
# abbas abu / abu abbas 0.805 0.333 1.000 1.000
|
|
33
|
+
# putin vladimir vladimirovich /
|
|
34
|
+
# vladimir putin 0.679 0.357 0.500 1.000
|
|
35
|
+
# gazprom / gazprom neft 0.917 0.583 0.583 1.000
|
|
36
|
+
# kim jong un / kim yong chol 0.869 0.615 0.462 0.462
|
|
37
|
+
#
|
|
38
|
+
# Row one is an inverted name, which is the single most common query shape
|
|
39
|
+
# against these lists, which character comparison misses and sorting makes
|
|
40
|
+
# trivial. Row two adds a patronymic on one side, which sorting cannot
|
|
41
|
+
# absorb either and the set ratio is built for. Row three is two different
|
|
42
|
+
# companies, and the 1.000 is what that tolerance costs. Row four is a pair
|
|
43
|
+
# already written in the same order, where sorting *loses* the information
|
|
44
|
+
# that they are, and the character algorithms are the honest ones.
|
|
45
|
+
#
|
|
46
|
+
# No single column is the score. #32 blends them, and rows three and four
|
|
47
|
+
# are why it has to.
|
|
48
|
+
#
|
|
49
|
+
# Stage 3c is next door, in Phonetics, and not in here: Double Metaphone
|
|
50
|
+
# answers what a name *sounds* like with a key rather than a number, and a
|
|
51
|
+
# key cannot be blended with four scores or held to the contract below.
|
|
52
|
+
#
|
|
53
|
+
# ### Why pure Ruby
|
|
54
|
+
#
|
|
55
|
+
# `fuzzy_string_match` and the other C extensions are faster per call, and
|
|
56
|
+
# they would put a native build in the path of every application that
|
|
57
|
+
# installs this gem and in every container image the future service is
|
|
58
|
+
# deployed from. That is a poor trade at the volume involved: the corpus is
|
|
59
|
+
# roughly 46,000 name strings, and the inverted index (#31) narrows a query
|
|
60
|
+
# to a few hundred candidates before any of this runs.
|
|
61
|
+
#
|
|
62
|
+
# `rake benchmark:similarity` is what that costs, and the numbers are the
|
|
63
|
+
# argument. On name-length strings without a JIT, Jaro-Winkler runs about
|
|
64
|
+
# 20 us per pair and Levenshtein about 60. The token ratios cost what they
|
|
65
|
+
# are: the sort ratio is one Levenshtein call on a rearranged string and
|
|
66
|
+
# prices like one, and the set ratio is three and costs about 110. The whole
|
|
67
|
+
# of a 500-candidate query, all four on every name, is about 25 ms with a
|
|
68
|
+
# threshold passed and six times that without one; YJIT takes a factor of
|
|
69
|
+
# two and a half off both and puts a thresholded query near 10 ms. That is
|
|
70
|
+
# the budget a screening call has, and it is the reason a threshold is worth
|
|
71
|
+
# passing -- the exits below are most of the difference between those two
|
|
72
|
+
# numbers.
|
|
73
|
+
#
|
|
74
|
+
# ### The contract all four keep
|
|
75
|
+
#
|
|
76
|
+
# **Folded names in.** Nothing here normalizes anything -- see Normalizer
|
|
77
|
+
# for why the fold happens once, at one entry point, for both sides of a
|
|
78
|
+
# comparison. A caller passes `form.value`, or `form.tokens` to either of
|
|
79
|
+
# the token ratios; passing raw publisher text instead scores the case and
|
|
80
|
+
# the punctuation rather than the name.
|
|
81
|
+
#
|
|
82
|
+
# **A similarity out, not a distance.** 1.0 is identical, 0.0 is nothing in
|
|
83
|
+
# common, and the number is a Float that is never rounded here. The scorer
|
|
84
|
+
# works in 0..100 and does its own rounding; rounding twice is how a
|
|
85
|
+
# threshold comparison starts disagreeing with the number printed beside it.
|
|
86
|
+
#
|
|
87
|
+
# **`threshold:` is an optimization, not a filter.** Passing one lets the
|
|
88
|
+
# algorithm stop as soon as the score provably cannot reach it, and any
|
|
89
|
+
# score below it is reported as 0.0 rather than computed exactly:
|
|
90
|
+
#
|
|
91
|
+
# Similarity::Levenshtein.call("gazprom", "gazprom neft") # => 0.5833...
|
|
92
|
+
# Similarity::Levenshtein.call("gazprom", "gazprom neft", threshold: 0.8)
|
|
93
|
+
# # => 0.0
|
|
94
|
+
#
|
|
95
|
+
# A score at or above the threshold is exactly the score the same call
|
|
96
|
+
# without a threshold returns -- the early exits are bounds on what a pair
|
|
97
|
+
# can reach, never approximations of what it did reach. `ceiling` on each
|
|
98
|
+
# algorithm is that bound, exposed so it can be held to that promise. How
|
|
99
|
+
# much it is worth differs: Levenshtein's is tight, Jaro-Winkler's is loose
|
|
100
|
+
# because the prefix bonus can add 0.4 to anything, and TokenSet has none at
|
|
101
|
+
# all, because a name that is a subset of another scores 1.0 at any length.
|
|
102
|
+
#
|
|
103
|
+
# @api private
|
|
104
|
+
module Similarity
|
|
105
|
+
extend T::Sig
|
|
106
|
+
extend T::Helpers
|
|
107
|
+
|
|
108
|
+
# Called as `Similarity.threshold!`, which is where `raise` comes from.
|
|
109
|
+
requires_ancestor { Kernel }
|
|
110
|
+
|
|
111
|
+
# A folded name, as either the string or the tokens it splits into. The
|
|
112
|
+
# token ratios take either on either side, because their two callers hold
|
|
113
|
+
# different things: a Form already carries `tokens` and should not pay for
|
|
114
|
+
# a split per comparison, while a spec, a console and the benchmark are
|
|
115
|
+
# written in strings.
|
|
116
|
+
Value = T.type_alias { T.any(String, T::Array[String]) }
|
|
117
|
+
|
|
118
|
+
module_function
|
|
119
|
+
|
|
120
|
+
# A threshold as a Float, or an InvalidArgument.
|
|
121
|
+
#
|
|
122
|
+
# The 0..1 range is checked rather than assumed because the surrounding
|
|
123
|
+
# library speaks in 0..100 -- the scorer's weights, its thresholds and
|
|
124
|
+
# everything a compliance user reads are percentages -- and a `85` that
|
|
125
|
+
# arrives here unchecked does not fail. It silently rejects every pair,
|
|
126
|
+
# which reads as "nothing matched" and is the one failure this domain
|
|
127
|
+
# cannot afford.
|
|
128
|
+
sig { params(threshold: Numeric).returns(Float).checked(:tests) }
|
|
129
|
+
def threshold!(threshold)
|
|
130
|
+
cutoff = threshold.to_f
|
|
131
|
+
return cutoff if cutoff.between?(0.0, 1.0)
|
|
132
|
+
|
|
133
|
+
raise InvalidArgument,
|
|
134
|
+
"threshold must be between 0.0 and 1.0, got #{threshold.inspect} -- " \
|
|
135
|
+
"these are similarities on a 0..1 scale, not percentages"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The form both algorithms work in: an Array of codepoints.
|
|
139
|
+
#
|
|
140
|
+
# Codepoints rather than characters because comparing Integers is cheaper
|
|
141
|
+
# than comparing one-character Strings, and both algorithms compare in
|
|
142
|
+
# their innermost loop. Codepoints rather than bytes because a byte is not
|
|
143
|
+
# a character outside ASCII, and half of these names are not ASCII: `ж` is
|
|
144
|
+
# two bytes, and a byte-wise edit distance would charge two edits for
|
|
145
|
+
# changing one letter and score Cyrillic pairs against a different scale
|
|
146
|
+
# than Latin ones.
|
|
147
|
+
#
|
|
148
|
+
# A string that is not valid UTF-8 is scrubbed rather than raising, for
|
|
149
|
+
# the reason Form repairs one: a single stray byte in a government file
|
|
150
|
+
# must not take a whole index build down with it. Normalizer has already
|
|
151
|
+
# done this to anything that came through it.
|
|
152
|
+
sig { params(string: String).returns(T::Array[Integer]).checked(:tests) }
|
|
153
|
+
def codepoints(string) = string.valid_encoding? ? string.codepoints : string.scrub.codepoints
|
|
154
|
+
|
|
155
|
+
# The form both token ratios work in: an Array of tokens.
|
|
156
|
+
#
|
|
157
|
+
# `String#split` with no argument, which is all the splitting a folded
|
|
158
|
+
# value needs: Normalizer's stage 4 turned every punctuation mark into a
|
|
159
|
+
# space and stage 5 collapsed the runs, so whitespace is the only boundary
|
|
160
|
+
# left in the string and there are no empty tokens at either end.
|
|
161
|
+
#
|
|
162
|
+
# An Array is taken as it stands. That is the path that matters -- the
|
|
163
|
+
# index (#31) hands the scorer a few hundred candidates, each carrying
|
|
164
|
+
# several names, and every one of those names has already been split once
|
|
165
|
+
# by the Form it lives in.
|
|
166
|
+
#
|
|
167
|
+
# Scrubbed first when it has to be, for the reason `codepoints` scrubs and
|
|
168
|
+
# Form repairs: `String#split` raises on a byte sequence that is not valid
|
|
169
|
+
# UTF-8, and one stray byte in a government file must not take an index
|
|
170
|
+
# build down with it.
|
|
171
|
+
sig { params(value: Value).returns(T::Array[String]).checked(:tests) }
|
|
172
|
+
def tokens(value)
|
|
173
|
+
return value unless value.is_a?(String)
|
|
174
|
+
|
|
175
|
+
(value.valid_encoding? ? value : value.scrub).split
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
require "active_sanction/similarity/jaro_winkler"
|
|
181
|
+
require "active_sanction/similarity/levenshtein"
|
|
182
|
+
require "active_sanction/similarity/token_sort"
|
|
183
|
+
require "active_sanction/similarity/token_set"
|