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,235 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/country"
|
|
7
|
+
require "active_sanction/entity"
|
|
8
|
+
require "active_sanction/identifier"
|
|
9
|
+
require "active_sanction/normalizer"
|
|
10
|
+
require "active_sanction/partial_date"
|
|
11
|
+
|
|
12
|
+
module ActiveSanction
|
|
13
|
+
module Scorer
|
|
14
|
+
# What the caller knows about the person or company being screened.
|
|
15
|
+
#
|
|
16
|
+
# subject = ActiveSanction::Scorer::Subject.new(
|
|
17
|
+
# name: "Bosco Ntaganda",
|
|
18
|
+
# type: :individual,
|
|
19
|
+
# dates_of_birth: "1973",
|
|
20
|
+
# nationalities: %w[CD],
|
|
21
|
+
# identifiers: [{ kind: :passport, value: "AB-123 456" }]
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# subject.form.value # => "bosco ntaganda"
|
|
25
|
+
#
|
|
26
|
+
# One side of every comparison the scorer makes, and the mirror image of
|
|
27
|
+
# the Entity on the other side: the same four kinds of evidence, arriving
|
|
28
|
+
# from an application's own customer record rather than from a government.
|
|
29
|
+
#
|
|
30
|
+
# ### Only `name` is required, and that is the shape of the problem
|
|
31
|
+
#
|
|
32
|
+
# Most callers have a name and little else, and most records carry less
|
|
33
|
+
# than that -- Canada supplies no aliases and often no date of birth. So
|
|
34
|
+
# every field but the name is optional on both sides, and a field absent
|
|
35
|
+
# on either side is neutral rather than a conflict. See Adjustments, where
|
|
36
|
+
# that rule is the difference between a screening tool and a tool that
|
|
37
|
+
# systematically under-scores the sparser lists.
|
|
38
|
+
#
|
|
39
|
+
# ### `type` decides two things
|
|
40
|
+
#
|
|
41
|
+
# It is the entity type the caller is asking about, and it does two jobs
|
|
42
|
+
# that are easy to confuse. It selects the normalizer's stoplists, so a
|
|
43
|
+
# company name is folded with its legal form stripped -- and both sides of
|
|
44
|
+
# a comparison have to be folded the same way, which is why the scorer
|
|
45
|
+
# folds each candidate name under its own entity's type. And it filters:
|
|
46
|
+
# a subject that says `:individual` is never scored against a vessel, at
|
|
47
|
+
# any name similarity. See Scorer.
|
|
48
|
+
#
|
|
49
|
+
# Passing no type is a legitimate answer and a different question -- no
|
|
50
|
+
# stoplist, no filter -- rather than a worse one.
|
|
51
|
+
#
|
|
52
|
+
# ### The fold happens once, here
|
|
53
|
+
#
|
|
54
|
+
# `form` is the folded name, produced by the one `Normalizer.call` every
|
|
55
|
+
# other stage uses, and held for the life of the subject. A screening call
|
|
56
|
+
# compares one subject against a few hundred candidates, and folding the
|
|
57
|
+
# query per candidate would be the same string folded a few hundred times.
|
|
58
|
+
#
|
|
59
|
+
# A caller that has already folded a name passes the Form, which is what
|
|
60
|
+
# screening one name against several indexes should do.
|
|
61
|
+
#
|
|
62
|
+
# ### Where Query fits
|
|
63
|
+
#
|
|
64
|
+
# This is the scorer's input, not the library's public screening API. The
|
|
65
|
+
# `Query` object (#33) validates what a host application sends -- a
|
|
66
|
+
# threshold, a limit, a source filter -- and builds one of these for the
|
|
67
|
+
# matcher to score with. Everything on this class is evidence about a
|
|
68
|
+
# subject; nothing on it is a search option.
|
|
69
|
+
#
|
|
70
|
+
# Instances are frozen on construction and compare by value.
|
|
71
|
+
class Subject
|
|
72
|
+
extend T::Sig
|
|
73
|
+
|
|
74
|
+
# @api private
|
|
75
|
+
MEMBERS = T.let(%i[name type dates_of_birth nationalities identifiers].freeze, T::Array[Symbol])
|
|
76
|
+
|
|
77
|
+
# The folded name every comparison runs against.
|
|
78
|
+
sig { returns(Normalizer::Form) }
|
|
79
|
+
attr_reader :form
|
|
80
|
+
|
|
81
|
+
sig { returns(T.nilable(Symbol)) }
|
|
82
|
+
attr_reader :type
|
|
83
|
+
|
|
84
|
+
sig { returns(T::Array[PartialDate]) }
|
|
85
|
+
attr_reader :dates_of_birth
|
|
86
|
+
|
|
87
|
+
# As published by the caller, in the caller's own vocabulary: `RU`,
|
|
88
|
+
# `Russia` and `Russian Federation` are all fine here. See #countries
|
|
89
|
+
# for the resolved form the scorer compares on.
|
|
90
|
+
sig { returns(T::Array[String]) }
|
|
91
|
+
attr_reader :nationalities
|
|
92
|
+
|
|
93
|
+
sig { returns(T::Array[Identifier]) }
|
|
94
|
+
attr_reader :identifiers
|
|
95
|
+
|
|
96
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
97
|
+
def self.from_h(hash)
|
|
98
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
99
|
+
unknown = attributes.keys - MEMBERS
|
|
100
|
+
raise InvalidArgument, "unknown Subject attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
101
|
+
|
|
102
|
+
T.unsafe(self).new(**attributes)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `name` is a String, a Name or an already-folded Form. The three
|
|
106
|
+
# collections each accept a single value as a collection of one, since
|
|
107
|
+
# `dates_of_birth: "1973"` is what a caller with one date writes.
|
|
108
|
+
#
|
|
109
|
+
# Dates accept anything PartialDate reads, including the free text these
|
|
110
|
+
# lists publish; identifiers accept an Identifier, its hash, or a bare
|
|
111
|
+
# document number, which becomes an identifier of unstated kind.
|
|
112
|
+
sig do
|
|
113
|
+
params(name: T.untyped, type: T.untyped, dates_of_birth: T.untyped, nationalities: T.untyped,
|
|
114
|
+
identifiers: T.untyped).void
|
|
115
|
+
end
|
|
116
|
+
def initialize(name:, type: nil, dates_of_birth: [], nationalities: [], identifiers: [])
|
|
117
|
+
@type = T.let(type!(type), T.nilable(Symbol))
|
|
118
|
+
@form = T.let(form!(name), Normalizer::Form)
|
|
119
|
+
@dates_of_birth = T.let(Array(dates_of_birth).map { |value| date!(value) }.freeze, T::Array[PartialDate])
|
|
120
|
+
@nationalities = T.let(strings(nationalities), T::Array[String])
|
|
121
|
+
@identifiers = T.let(Array(identifiers).map { |value| identifier!(value) }.freeze, T::Array[Identifier])
|
|
122
|
+
@countries = T.let(resolve(@nationalities), T::Array[String])
|
|
123
|
+
freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The name as the caller wrote it, which is what a report quotes back.
|
|
127
|
+
sig { returns(String) }
|
|
128
|
+
def name = form.original
|
|
129
|
+
|
|
130
|
+
# The alpha-2 codes #nationalities resolved to, which may be shorter
|
|
131
|
+
# than the list it came from: a value Country does not recognize is
|
|
132
|
+
# dropped here rather than guessed at, and the scorer treats a subject
|
|
133
|
+
# whose countries did not all resolve as one that cannot contradict a
|
|
134
|
+
# record. See Adjustments.
|
|
135
|
+
sig { returns(T::Array[String]) }
|
|
136
|
+
attr_reader :countries
|
|
137
|
+
|
|
138
|
+
# True when every nationality the caller gave resolved to a country.
|
|
139
|
+
# A conflict penalty is only applied when both sides can say this.
|
|
140
|
+
sig { returns(T::Boolean) }
|
|
141
|
+
def countries? = nationalities.any? && countries.size == nationalities.uniq.size
|
|
142
|
+
|
|
143
|
+
sig { returns(T::Boolean) }
|
|
144
|
+
def dates_of_birth? = dates_of_birth.any?
|
|
145
|
+
|
|
146
|
+
sig { returns(T::Boolean) }
|
|
147
|
+
def identifiers? = identifiers.any?
|
|
148
|
+
|
|
149
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
150
|
+
def to_h
|
|
151
|
+
{
|
|
152
|
+
name: name,
|
|
153
|
+
type: type,
|
|
154
|
+
dates_of_birth: dates_of_birth.map(&:to_h),
|
|
155
|
+
nationalities: nationalities,
|
|
156
|
+
identifiers: identifiers.map(&:to_h)
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
161
|
+
def ==(other)
|
|
162
|
+
return false unless other.instance_of?(self.class)
|
|
163
|
+
|
|
164
|
+
to_h == other.to_h
|
|
165
|
+
end
|
|
166
|
+
alias eql? ==
|
|
167
|
+
|
|
168
|
+
sig { returns(Integer) }
|
|
169
|
+
def hash = [self.class, to_h].hash
|
|
170
|
+
|
|
171
|
+
sig { returns(String) }
|
|
172
|
+
def inspect = "#<#{self.class} #{name.inspect}#{" type=#{type.inspect}" if type}>"
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# A Form is taken as it stands, and it is the caller's job to have
|
|
177
|
+
# folded it under the same type -- there is no way to check, since a
|
|
178
|
+
# Form does not record which stoplist produced it, and re-folding it
|
|
179
|
+
# here would silently discard the caller's intent. See Normalizer on
|
|
180
|
+
# why one fold, once, is the whole point.
|
|
181
|
+
sig { params(value: T.untyped).returns(Normalizer::Form) }
|
|
182
|
+
def form!(value)
|
|
183
|
+
return value if value.is_a?(Normalizer::Form)
|
|
184
|
+
|
|
185
|
+
string = value.to_s.strip
|
|
186
|
+
raise InvalidArgument, "name is required -- there is nothing to screen without one" if string.empty?
|
|
187
|
+
|
|
188
|
+
folded = Normalizer.call(string, type: type)
|
|
189
|
+
raise InvalidArgument, "name folds away to nothing: #{string.inspect}" if folded.empty?
|
|
190
|
+
|
|
191
|
+
folded
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
sig { params(value: T.untyped).returns(T.nilable(Symbol)) }
|
|
195
|
+
def type!(value)
|
|
196
|
+
return nil if value.nil? || value.to_s.empty?
|
|
197
|
+
|
|
198
|
+
symbol = value.to_s.downcase.to_sym
|
|
199
|
+
return symbol if Entity::TYPES.include?(symbol)
|
|
200
|
+
|
|
201
|
+
raise InvalidArgument, "unknown type #{symbol.inspect}, expected one of #{Entity::TYPES.join(", ")} or nil"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig { params(value: T.untyped).returns(PartialDate) }
|
|
205
|
+
def date!(value)
|
|
206
|
+
case value
|
|
207
|
+
when PartialDate then value
|
|
208
|
+
when Hash then PartialDate.from_h(value)
|
|
209
|
+
else PartialDate.parse(value) || raise(InvalidArgument, "not a date of birth: #{value.inspect}")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
sig { params(value: T.untyped).returns(Identifier) }
|
|
214
|
+
def identifier!(value)
|
|
215
|
+
case value
|
|
216
|
+
when Identifier then value
|
|
217
|
+
when Hash then Identifier.from_h(value)
|
|
218
|
+
else Identifier.new(value: value)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A value Country does not recognize is dropped rather than guessed at.
|
|
223
|
+
# #countries? is how a caller tells a fully resolved list from a partly
|
|
224
|
+
# resolved one, which is the difference between a nationality that may
|
|
225
|
+
# contradict a record and one that may only agree with it.
|
|
226
|
+
sig { params(values: T::Array[String]).returns(T::Array[String]) }
|
|
227
|
+
def resolve(values) = values.filter_map { |value| Country.code(value) }.uniq.freeze
|
|
228
|
+
|
|
229
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
230
|
+
def strings(value)
|
|
231
|
+
Array(value).map { |entry| -entry.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Scorer
|
|
8
|
+
# Every number the scorer uses, with a default and a reason for it.
|
|
9
|
+
#
|
|
10
|
+
# ActiveSanction::Scorer::Weights.default.token_set # => 0.45
|
|
11
|
+
# ActiveSanction::Scorer::Weights.default.dob_conflict # => -35.0
|
|
12
|
+
#
|
|
13
|
+
# ActiveSanction.configure do |c|
|
|
14
|
+
# c.scorer_weights = { dob_conflict: -20.0 }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# There are two kinds of number here and they are in different units,
|
|
18
|
+
# which is the thing to know before changing one.
|
|
19
|
+
#
|
|
20
|
+
# ### The name shares, which are fractions of the name score
|
|
21
|
+
#
|
|
22
|
+
# `jaro_winkler`, `levenshtein`, `token_sort`, `token_set` and `phonetic`
|
|
23
|
+
# are shares of one blended similarity. They are each between 0 and 1 and
|
|
24
|
+
# they must sum to exactly 1, which is what makes the name score a
|
|
25
|
+
# percentage rather than an arbitrary total: two identical names score
|
|
26
|
+
# 100 because every share agreed, and a share that agreed on nothing
|
|
27
|
+
# contributes nothing.
|
|
28
|
+
#
|
|
29
|
+
# The defaults are set from what the four algorithms disagree about --
|
|
30
|
+
# Similarity's own table is the argument, and it is worth reading beside
|
|
31
|
+
# this:
|
|
32
|
+
#
|
|
33
|
+
# JW Lev Sort Set Phon blend
|
|
34
|
+
# abbas abu / abu abbas 0.805 0.333 1.000 1.000 1.000 90.4
|
|
35
|
+
# putin vladimir vladimirovich /
|
|
36
|
+
# vladimir putin 0.679 0.357 0.500 1.000 1.000 76.2
|
|
37
|
+
# gazprom / gazprom neft 0.917 0.583 0.583 1.000 1.000 84.2
|
|
38
|
+
# kim jong un / kim yong chol 0.869 0.615 0.462 0.462 0.667 54.8
|
|
39
|
+
#
|
|
40
|
+
# **The token ratios carry most of the weight**, because the query shapes
|
|
41
|
+
# this corpus actually produces are rearrangements. An individual is
|
|
42
|
+
# published surname-first and typed given-name-first; a patronymic is on
|
|
43
|
+
# the record and not in the query. Row one and row two are both true
|
|
44
|
+
# matches that the character algorithms score in the sixties and the
|
|
45
|
+
# seventies, and a blend that let them decide would miss the two most
|
|
46
|
+
# common true positives there are.
|
|
47
|
+
#
|
|
48
|
+
# **`token_set` is the largest single share** because a 1.0 from it means
|
|
49
|
+
# something specific and strong: every word of the shorter name appears in
|
|
50
|
+
# the longer one. That is the shape of nearly every honest partial query.
|
|
51
|
+
#
|
|
52
|
+
# **The character algorithms are the brake.** Row four is the case they
|
|
53
|
+
# exist for -- two names already written in the same order, where sorting
|
|
54
|
+
# loses the information that they are, and the token ratios happily score
|
|
55
|
+
# a different person at 0.462. Levenshtein's share is the smallest because
|
|
56
|
+
# it is the harshest measure in the set: on a name that is one token
|
|
57
|
+
# longer it is already down in the fifties, and giving it more would pull
|
|
58
|
+
# every true partial match down with it.
|
|
59
|
+
#
|
|
60
|
+
# **The phonetic share is small and it is a share, not a bonus.** A shared
|
|
61
|
+
# Double Metaphone key is real evidence -- it is what puts `QADHAFI` and
|
|
62
|
+
# `GADDAFI` together -- and it is weak evidence, because `HSN` is the key
|
|
63
|
+
# for `HUSSEIN` and equally for `HASSAN`. Five points is what it is worth
|
|
64
|
+
# on its own; the reason it is inside the sum rather than added on top is
|
|
65
|
+
# that a bonus would put an identical pair over 100 and need clamping to
|
|
66
|
+
# get back, and a score that reaches its ceiling by two different routes
|
|
67
|
+
# is one nobody can reason about.
|
|
68
|
+
#
|
|
69
|
+
# ### The adjustments, which are points on the 0..100 score
|
|
70
|
+
#
|
|
71
|
+
# These are added to the name score, not multiplied into it, because they
|
|
72
|
+
# are separate evidence rather than a re-reading of the name. A passport
|
|
73
|
+
# number is not "more name"; it is the thing that makes a mediocre name
|
|
74
|
+
# match decisive, and a boost that scaled with the name score could not do
|
|
75
|
+
# that.
|
|
76
|
+
#
|
|
77
|
+
# `identifier_match` is 40 and is meant to be decisive: a name in the
|
|
78
|
+
# fifties plus the right passport number clears any sane threshold, which
|
|
79
|
+
# is the entire reason a screening tool asks for document numbers.
|
|
80
|
+
#
|
|
81
|
+
# `dob_conflict` at -35 is the one number the acceptance criteria pin
|
|
82
|
+
# down. A name-identical pair scores 100, and a genuine date-of-birth
|
|
83
|
+
# conflict has to put it under the threshold rather than merely rank it
|
|
84
|
+
# lower -- 65 is under every default this library ships. The exact match
|
|
85
|
+
# is worth less than the conflict costs on purpose: sharing a birthday
|
|
86
|
+
# with a listed person is a coincidence a few thousand people have, and
|
|
87
|
+
# not having theirs is not.
|
|
88
|
+
#
|
|
89
|
+
# `dob_overlap` at 6 is what a year-only date is worth. Most of these
|
|
90
|
+
# records carry one -- see PartialDate on why the type exists -- and
|
|
91
|
+
# `1948` against `1948-12-10` is agreement worth noting and not worth much.
|
|
92
|
+
#
|
|
93
|
+
# Nationality moves the score least in both directions, because it is the
|
|
94
|
+
# softest of the three. People hold two passports, lists record the
|
|
95
|
+
# country a person was born in as often as the one they are a citizen of,
|
|
96
|
+
# and a conflict there is weaker evidence than a date conflict by some way.
|
|
97
|
+
#
|
|
98
|
+
# ### Penalties are stored negative
|
|
99
|
+
#
|
|
100
|
+
# `dob_conflict` is `-35.0` rather than `35.0` subtracted somewhere else,
|
|
101
|
+
# so that a Reason's contribution is the number in this object and a host
|
|
102
|
+
# reading a configuration can see which way each one pushes. A boost
|
|
103
|
+
# written negative, or a penalty written positive, is refused.
|
|
104
|
+
#
|
|
105
|
+
# Instances are frozen on construction and compare by value.
|
|
106
|
+
class Weights
|
|
107
|
+
extend T::Sig
|
|
108
|
+
|
|
109
|
+
# The five shares of the blended name score. They sum to 1.
|
|
110
|
+
#
|
|
111
|
+
# @api private
|
|
112
|
+
NAME_SHARES = T.let(%i[jaro_winkler levenshtein token_sort token_set phonetic].freeze, T::Array[Symbol])
|
|
113
|
+
|
|
114
|
+
# Points added to the name score. Anything listed in PENALTIES must be
|
|
115
|
+
# zero or negative; everything else here must be zero or positive.
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
ADJUSTMENTS = T.let(%i[
|
|
119
|
+
low_quality_alias identifier_match dob_exact dob_overlap dob_conflict
|
|
120
|
+
nationality_match nationality_conflict
|
|
121
|
+
].freeze, T::Array[Symbol])
|
|
122
|
+
|
|
123
|
+
# @api private
|
|
124
|
+
PENALTIES = T.let(%i[low_quality_alias dob_conflict nationality_conflict].freeze, T::Array[Symbol])
|
|
125
|
+
|
|
126
|
+
# @api private
|
|
127
|
+
MEMBERS = T.let((NAME_SHARES + ADJUSTMENTS).freeze, T::Array[Symbol])
|
|
128
|
+
|
|
129
|
+
# Every weight this library ships, and the numbers the committed
|
|
130
|
+
# accuracy report was measured with. A host tuning one starts from
|
|
131
|
+
# here, changes what it means to and leaves the rest alone -- the
|
|
132
|
+
# shares are held to summing to 1.0, so they move in pairs.
|
|
133
|
+
DEFAULTS = T.let({
|
|
134
|
+
jaro_winkler: 0.15,
|
|
135
|
+
levenshtein: 0.10,
|
|
136
|
+
token_sort: 0.25,
|
|
137
|
+
token_set: 0.45,
|
|
138
|
+
phonetic: 0.05,
|
|
139
|
+
low_quality_alias: -10.0,
|
|
140
|
+
identifier_match: 40.0,
|
|
141
|
+
dob_exact: 15.0,
|
|
142
|
+
dob_overlap: 6.0,
|
|
143
|
+
dob_conflict: -35.0,
|
|
144
|
+
nationality_match: 6.0,
|
|
145
|
+
nationality_conflict: -12.0
|
|
146
|
+
}.freeze, T::Hash[Symbol, Float])
|
|
147
|
+
|
|
148
|
+
# Floating point addition of five decimal fractions does not land on 1.0
|
|
149
|
+
# exactly, and refusing a set of shares over the last bit of a Float
|
|
150
|
+
# would be refusing arithmetic rather than a misconfiguration.
|
|
151
|
+
#
|
|
152
|
+
# @api private
|
|
153
|
+
SHARE_TOLERANCE = T.let(1e-9, Float)
|
|
154
|
+
|
|
155
|
+
# Spelled out rather than defined from MEMBERS in a loop, because a
|
|
156
|
+
# reader Sorbet cannot see is a reader every call site has to be
|
|
157
|
+
# `T.unsafe` to reach. Each one reads the hash; nothing here is stored
|
|
158
|
+
# twice.
|
|
159
|
+
sig { returns(Float) }
|
|
160
|
+
def jaro_winkler = fetch(:jaro_winkler)
|
|
161
|
+
|
|
162
|
+
sig { returns(Float) }
|
|
163
|
+
def levenshtein = fetch(:levenshtein)
|
|
164
|
+
|
|
165
|
+
sig { returns(Float) }
|
|
166
|
+
def token_sort = fetch(:token_sort)
|
|
167
|
+
|
|
168
|
+
sig { returns(Float) }
|
|
169
|
+
def token_set = fetch(:token_set)
|
|
170
|
+
|
|
171
|
+
sig { returns(Float) }
|
|
172
|
+
def phonetic = fetch(:phonetic)
|
|
173
|
+
|
|
174
|
+
sig { returns(Float) }
|
|
175
|
+
def low_quality_alias = fetch(:low_quality_alias)
|
|
176
|
+
|
|
177
|
+
sig { returns(Float) }
|
|
178
|
+
def identifier_match = fetch(:identifier_match)
|
|
179
|
+
|
|
180
|
+
sig { returns(Float) }
|
|
181
|
+
def dob_exact = fetch(:dob_exact)
|
|
182
|
+
|
|
183
|
+
sig { returns(Float) }
|
|
184
|
+
def dob_overlap = fetch(:dob_overlap)
|
|
185
|
+
|
|
186
|
+
sig { returns(Float) }
|
|
187
|
+
def dob_conflict = fetch(:dob_conflict)
|
|
188
|
+
|
|
189
|
+
sig { returns(Float) }
|
|
190
|
+
def nationality_match = fetch(:nationality_match)
|
|
191
|
+
|
|
192
|
+
sig { returns(Float) }
|
|
193
|
+
def nationality_conflict = fetch(:nationality_conflict)
|
|
194
|
+
|
|
195
|
+
class << self
|
|
196
|
+
extend T::Sig
|
|
197
|
+
|
|
198
|
+
# The shipped numbers. Built at load, so nothing has to synchronize
|
|
199
|
+
# its construction.
|
|
200
|
+
sig { returns(Weights) }
|
|
201
|
+
def default = DEFAULT
|
|
202
|
+
|
|
203
|
+
# The shipped numbers with some replaced, which is what a host almost
|
|
204
|
+
# always wants:
|
|
205
|
+
#
|
|
206
|
+
# Weights.build(dob_conflict: -20.0)
|
|
207
|
+
#
|
|
208
|
+
# A Weights passes through, so a caller holding either can hand this
|
|
209
|
+
# whatever it has.
|
|
210
|
+
sig { params(value: T.untyped).returns(Weights) }
|
|
211
|
+
def build(value)
|
|
212
|
+
return default if value.nil?
|
|
213
|
+
return value if value.is_a?(Weights)
|
|
214
|
+
raise InvalidArgument, "expected a #{self} or a Hash of weights, got #{value.class}" unless value.is_a?(Hash)
|
|
215
|
+
|
|
216
|
+
T.unsafe(default).merge(**value.to_h { |member, weight| [member.to_s.to_sym, weight] })
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Every member defaults, so `new` and `new(dob_conflict: -20.0)` are
|
|
221
|
+
# both a complete set. Unlike the normalizer's dictionaries there is no
|
|
222
|
+
# danger in a partial replacement here: a number left out is the shipped
|
|
223
|
+
# one, and the shares are checked to sum to 1 whatever a caller passed.
|
|
224
|
+
sig { params(overrides: T.untyped).void }
|
|
225
|
+
def initialize(**overrides)
|
|
226
|
+
unknown = overrides.keys - MEMBERS
|
|
227
|
+
raise InvalidArgument, "unknown weight(s): #{unknown.join(", ")}" if unknown.any?
|
|
228
|
+
|
|
229
|
+
@weights = T.let(DEFAULTS.merge(overrides).to_h { |member, weight| [member, number!(member, weight)] }.freeze,
|
|
230
|
+
T::Hash[Symbol, Float])
|
|
231
|
+
validate_shares!
|
|
232
|
+
freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# These weights with some replaced.
|
|
236
|
+
sig { params(overrides: T.untyped).returns(Weights) }
|
|
237
|
+
def merge(**overrides) = T.unsafe(self.class).new(**@weights, **overrides)
|
|
238
|
+
|
|
239
|
+
sig { params(member: Symbol).returns(Float) }
|
|
240
|
+
def fetch(member) = @weights.fetch(member)
|
|
241
|
+
|
|
242
|
+
sig { returns(T::Hash[Symbol, Float]) }
|
|
243
|
+
def to_h = @weights.dup
|
|
244
|
+
|
|
245
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
246
|
+
def ==(other)
|
|
247
|
+
return false unless other.instance_of?(self.class)
|
|
248
|
+
|
|
249
|
+
to_h == other.to_h
|
|
250
|
+
end
|
|
251
|
+
alias eql? ==
|
|
252
|
+
|
|
253
|
+
sig { returns(Integer) }
|
|
254
|
+
def hash = [self.class, to_h].hash
|
|
255
|
+
|
|
256
|
+
sig { returns(String) }
|
|
257
|
+
def inspect = "#<#{self.class} #{to_h.map { |member, weight| "#{member}=#{weight}" }.join(" ")}>"
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
sig { params(member: Symbol, value: T.untyped).returns(Float) }
|
|
262
|
+
def number!(member, value)
|
|
263
|
+
number = begin
|
|
264
|
+
Float(value)
|
|
265
|
+
rescue TypeError, ArgumentError
|
|
266
|
+
raise InvalidArgument, "#{member} must be a number, got #{value.inspect}"
|
|
267
|
+
end
|
|
268
|
+
direction!(member, number)
|
|
269
|
+
number
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# A boost written negative is a configuration that quietly inverts a
|
|
273
|
+
# signal -- a passport match that lowers a score -- and it would look
|
|
274
|
+
# exactly like a scorer bug from outside.
|
|
275
|
+
sig { params(member: Symbol, number: Float).void }
|
|
276
|
+
def direction!(member, number)
|
|
277
|
+
if PENALTIES.include?(member)
|
|
278
|
+
raise InvalidArgument, "#{member} is a penalty and cannot be positive, got #{number}" if number.positive?
|
|
279
|
+
elsif number.negative?
|
|
280
|
+
raise InvalidArgument, "#{member} is a boost and cannot be negative, got #{number}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
sig { void }
|
|
285
|
+
def validate_shares!
|
|
286
|
+
NAME_SHARES.each do |share|
|
|
287
|
+
weight = @weights.fetch(share)
|
|
288
|
+
raise InvalidArgument, "#{share} must be between 0 and 1, got #{weight}" unless weight.between?(0.0, 1.0)
|
|
289
|
+
end
|
|
290
|
+
total = NAME_SHARES.sum { |share| @weights.fetch(share) }
|
|
291
|
+
return if (total - 1.0).abs <= SHARE_TOLERANCE
|
|
292
|
+
|
|
293
|
+
raise InvalidArgument,
|
|
294
|
+
"the name shares must sum to 1.0, got #{total.round(6)} -- " \
|
|
295
|
+
"#{NAME_SHARES.map { |share| "#{share}=#{@weights.fetch(share)}" }.join(", ")}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Last, because building it runs #initialize, which calls every private
|
|
299
|
+
# method above.
|
|
300
|
+
#
|
|
301
|
+
# @api private
|
|
302
|
+
DEFAULT = T.let(new, Weights)
|
|
303
|
+
private_constant :DEFAULT
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|