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,388 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/index"
|
|
9
|
+
require "active_sanction/match_result"
|
|
10
|
+
require "active_sanction/query"
|
|
11
|
+
require "active_sanction/scorer"
|
|
12
|
+
require "active_sanction/sources/definition"
|
|
13
|
+
require "active_sanction/storage"
|
|
14
|
+
require "active_sanction/version"
|
|
15
|
+
|
|
16
|
+
module ActiveSanction
|
|
17
|
+
# The screening call, and the object a server holds.
|
|
18
|
+
#
|
|
19
|
+
# matcher = ActiveSanction::Matcher.build(store)
|
|
20
|
+
#
|
|
21
|
+
# results = matcher.screen(
|
|
22
|
+
# name: "Bosco Ntaganda",
|
|
23
|
+
# type: :individual,
|
|
24
|
+
# date_of_birth: "1973",
|
|
25
|
+
# countries: %w[CD],
|
|
26
|
+
# sources: %i[ofac_sdn un_consolidated],
|
|
27
|
+
# threshold: 75,
|
|
28
|
+
# limit: 10
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# results.first.score # => 100.0
|
|
32
|
+
# results.first.snapshot_id # => "sha256:9f86d081884c7d65..."
|
|
33
|
+
#
|
|
34
|
+
# Stage five, and the only one with nothing after it. It runs the pipeline
|
|
35
|
+
# the other four stages are: fold the query once (Normalizer), retrieve the
|
|
36
|
+
# names worth comparing (Index), score each of them with reasons (Scorer),
|
|
37
|
+
# then filter, sort, cap and stamp. Nothing here decides whether two names
|
|
38
|
+
# are the same person; what it decides is what a caller is handed and what a
|
|
39
|
+
# decision can be defended with.
|
|
40
|
+
#
|
|
41
|
+
# ### It is built once and then only read
|
|
42
|
+
#
|
|
43
|
+
# A Matcher holds an index, the checksum of every list in it, the weights it
|
|
44
|
+
# scores with and the candidate cap it retrieves with. All of it is fixed at
|
|
45
|
+
# construction and the object is frozen, so `screen` allocates locals and
|
|
46
|
+
# touches nothing shared. A web process builds one at boot and screens from
|
|
47
|
+
# every thread without a lock:
|
|
48
|
+
#
|
|
49
|
+
# MATCHER = ActiveSanction::Matcher.build(store) # in an initializer
|
|
50
|
+
# MATCHER.screen(name: params[:name]) # in a request
|
|
51
|
+
#
|
|
52
|
+
# **Nothing on the query path reads configuration.** That is a stronger
|
|
53
|
+
# statement than thread safety and it is the one that matters for an audit:
|
|
54
|
+
# a threshold, a weight or a candidate cap changed halfway through a batch
|
|
55
|
+
# cannot produce a run that is half one set of numbers and half another,
|
|
56
|
+
# because the numbers were read once -- into the Query, and into this.
|
|
57
|
+
#
|
|
58
|
+
# ### A sync does not update a matcher
|
|
59
|
+
#
|
|
60
|
+
# It builds a new one, and the application swaps its reference:
|
|
61
|
+
#
|
|
62
|
+
# MATCHER = ActiveSanction::Matcher.build(store) # after a sync
|
|
63
|
+
#
|
|
64
|
+
# A plain reassignment is enough on CRuby, where a reference assignment is
|
|
65
|
+
# atomic; `Concurrent::AtomicReference` is the portable spelling. Requests
|
|
66
|
+
# in flight keep the matcher they started with and finish against one
|
|
67
|
+
# consistent list version, which is what makes their results re-derivable --
|
|
68
|
+
# a matcher that mutated underneath a query would produce a result no
|
|
69
|
+
# snapshot checksum explains. See Index, which is immutable for this reason.
|
|
70
|
+
#
|
|
71
|
+
# ### An empty matcher is refused rather than built
|
|
72
|
+
#
|
|
73
|
+
# Screening against a list that is not there returns a clean report, and a
|
|
74
|
+
# clean report is the most expensive thing this library can get wrong. So a
|
|
75
|
+
# store with nothing in it raises NotSynced at build, a named source that
|
|
76
|
+
# has never been synced raises Storage::MissingSnapshot, and a query naming
|
|
77
|
+
# a source this matcher does not hold raises rather than quietly covering
|
|
78
|
+
# two of the three lists it was asked for.
|
|
79
|
+
#
|
|
80
|
+
# ### Where the backend seam goes
|
|
81
|
+
#
|
|
82
|
+
# This is the Local backend's implementation (#56): `Backend::Local#screen`
|
|
83
|
+
# is this call, and a hosted backend answers the same query with the same
|
|
84
|
+
# MatchResults against data somebody else keeps fresh. Which one answered is
|
|
85
|
+
# on every result. What a server holds is a Client (#55) rather than one of
|
|
86
|
+
# these directly, because a client is what pairs an index with the
|
|
87
|
+
# configuration it was built under; this stays the object to build by hand
|
|
88
|
+
# when a caller already has an index -- a spec, or a process screening one
|
|
89
|
+
# name against several list versions of the same store.
|
|
90
|
+
class Matcher
|
|
91
|
+
extend T::Sig
|
|
92
|
+
|
|
93
|
+
# Nothing has ever been synced, so there is nothing to screen against.
|
|
94
|
+
# Separate from Storage::MissingSnapshot, which is about one named list:
|
|
95
|
+
# this is an installation that has not run a sync yet, and the fix is a
|
|
96
|
+
# different sentence.
|
|
97
|
+
class NotSynced < StorageError; end
|
|
98
|
+
|
|
99
|
+
# Which lists this matcher holds, and the checksum of each. The stamp on
|
|
100
|
+
# every result comes from here.
|
|
101
|
+
sig { returns(T::Hash[Symbol, String]) }
|
|
102
|
+
attr_reader :snapshots
|
|
103
|
+
|
|
104
|
+
# The lists in here that arrived cryptographically attested -- read from a
|
|
105
|
+
# signed bundle (#57) that verified under a key this installation supplied
|
|
106
|
+
# -- sorted. Usually empty, because a list this installation fetched and
|
|
107
|
+
# parsed itself is not attested by anybody.
|
|
108
|
+
#
|
|
109
|
+
# Kept beside `snapshots` rather than folded into it because it is a fact
|
|
110
|
+
# about a different thing: a checksum says which list version answered, and
|
|
111
|
+
# this says who vouched for it. Every result the matcher produces carries
|
|
112
|
+
# both. See MatchResult#verified?.
|
|
113
|
+
sig { returns(T::Array[Symbol]) }
|
|
114
|
+
attr_reader :verified
|
|
115
|
+
|
|
116
|
+
sig { returns(Index) }
|
|
117
|
+
attr_reader :index
|
|
118
|
+
|
|
119
|
+
# What each signal was worth when this matcher was built, and what every
|
|
120
|
+
# result it produces records.
|
|
121
|
+
sig { returns(Scorer::Weights) }
|
|
122
|
+
attr_reader :weights
|
|
123
|
+
|
|
124
|
+
# How many names the index hands the scorer per query. See
|
|
125
|
+
# Configuration::DEFAULT_CANDIDATE_LIMIT -- and note that it bounds a
|
|
126
|
+
# query's `limit:` in practice, since a result cannot be returned for a
|
|
127
|
+
# name that was never retrieved.
|
|
128
|
+
sig { returns(Integer) }
|
|
129
|
+
attr_reader :candidate_limit
|
|
130
|
+
|
|
131
|
+
sig { returns(Symbol) }
|
|
132
|
+
attr_reader :backend
|
|
133
|
+
|
|
134
|
+
class << self
|
|
135
|
+
extend T::Sig
|
|
136
|
+
|
|
137
|
+
# A matcher over what a store holds, or over the lists named:
|
|
138
|
+
#
|
|
139
|
+
# ActiveSanction::Matcher.build # the configured store
|
|
140
|
+
# ActiveSanction::Matcher.build(store)
|
|
141
|
+
# ActiveSanction::Matcher.build(store, sources: %i[ofac_sdn])
|
|
142
|
+
#
|
|
143
|
+
# Snapshots are read one at a time and each is released before the next
|
|
144
|
+
# is opened, so building never holds every list in memory at once -- and
|
|
145
|
+
# each list's checksum is taken from the very snapshot that was indexed,
|
|
146
|
+
# rather than read separately afterwards, where a concurrent sync could
|
|
147
|
+
# put a stamp on results the list no longer explains.
|
|
148
|
+
#
|
|
149
|
+
# `sources: nil` means whatever is stored. Naming a list that has never
|
|
150
|
+
# been synced raises instead: a run that quietly covers two of the three
|
|
151
|
+
# lists an application configured is indistinguishable from one that
|
|
152
|
+
# covers all three, and both report the name clear.
|
|
153
|
+
sig do
|
|
154
|
+
params(store: T.untyped, sources: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
|
|
155
|
+
backend: T.untyped).returns(Matcher)
|
|
156
|
+
end
|
|
157
|
+
def build(store = nil, sources: nil, weights: nil, candidate_limit: nil,
|
|
158
|
+
backend: MatchResult::DEFAULT_BACKEND)
|
|
159
|
+
store ||= ActiveSanction.config.storage
|
|
160
|
+
builder = Index::Builder.new
|
|
161
|
+
checksums = T.let({}, T::Hash[Symbol, String])
|
|
162
|
+
attested = T.let([], T::Array[Symbol])
|
|
163
|
+
requested(store, sources).each do |key|
|
|
164
|
+
snapshot = store.fetch_snapshot(key)
|
|
165
|
+
checksums[key] = snapshot.checksum
|
|
166
|
+
# Read off the very snapshot that was indexed, for the reason its
|
|
167
|
+
# checksum is: a store asked again afterwards could answer about a
|
|
168
|
+
# different list.
|
|
169
|
+
attested << key if snapshot.trusted?
|
|
170
|
+
snapshot.entities.each { |entity| builder.add(entity) }
|
|
171
|
+
end
|
|
172
|
+
new(index: builder.build, snapshots: checksums, verified: attested, weights: weights,
|
|
173
|
+
candidate_limit: candidate_limit, backend: backend)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# The lists to index, in a deterministic order, or the exception that
|
|
179
|
+
# says why there are none.
|
|
180
|
+
sig { params(store: T.untyped, sources: T.untyped).returns(T::Array[Symbol]) }
|
|
181
|
+
def requested(store, sources)
|
|
182
|
+
unless sources.nil?
|
|
183
|
+
keys = Array(sources).map { |key| Sources::Definition.key!(key) }.uniq
|
|
184
|
+
raise InvalidArgument, "sources cannot be empty -- omit it to screen every stored list" if keys.empty?
|
|
185
|
+
|
|
186
|
+
return keys
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
stored = store.sources
|
|
190
|
+
raise NotSynced, nothing_stored(store) if stored.empty?
|
|
191
|
+
|
|
192
|
+
stored
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
sig { params(store: T.untyped).returns(String) }
|
|
196
|
+
def nothing_stored(store)
|
|
197
|
+
"#{store.class} holds no lists, so there is nothing to screen against and every name would come back " \
|
|
198
|
+
"clear. Sync one first -- ActiveSanction::Sources[:ofac_sdn].new.sync"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Built by .build, which is what a caller almost always wants. Taken
|
|
203
|
+
# directly by a caller that already has an index -- a process screening
|
|
204
|
+
# one name against several list versions, or a spec.
|
|
205
|
+
sig do
|
|
206
|
+
params(index: Index, snapshots: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
|
|
207
|
+
backend: T.untyped, verified: T.untyped).void
|
|
208
|
+
end
|
|
209
|
+
def initialize(index:, snapshots:, weights: nil, candidate_limit: nil, backend: MatchResult::DEFAULT_BACKEND,
|
|
210
|
+
verified: nil)
|
|
211
|
+
@index = index
|
|
212
|
+
@snapshots = T.let(snapshots!(snapshots), T::Hash[Symbol, String])
|
|
213
|
+
@verified = T.let(verified!(verified), T::Array[Symbol])
|
|
214
|
+
raise NotSynced, "the lists given hold no names to screen against" if index.empty?
|
|
215
|
+
|
|
216
|
+
@weights = T.let(Scorer::Weights.build(weights), Scorer::Weights)
|
|
217
|
+
@candidate_limit = T.let(candidate_limit!(candidate_limit), Integer)
|
|
218
|
+
@backend = T.let(backend.to_s.to_sym, Symbol)
|
|
219
|
+
freeze
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The hits, highest score first:
|
|
223
|
+
#
|
|
224
|
+
# matcher.screen(name: "Bosco Ntaganda", threshold: 75)
|
|
225
|
+
# matcher.screen("Bosco Ntaganda") # a name and nothing else
|
|
226
|
+
# matcher.screen(query, limit: 25) # a Query, with one option changed
|
|
227
|
+
#
|
|
228
|
+
# An empty array is a real answer and the common one -- most customers are
|
|
229
|
+
# not on a sanctions list. It is not the same answer as an exception, and
|
|
230
|
+
# everything that could make it a lie rather than a fact raises instead:
|
|
231
|
+
# see the note on an empty matcher above.
|
|
232
|
+
#
|
|
233
|
+
# ### One result per entity, not per name
|
|
234
|
+
#
|
|
235
|
+
# An entity is retrieved once for every one of its names the query looks
|
|
236
|
+
# like, and its score is the best of those names (see Scorer). So each
|
|
237
|
+
# entity is scored once and reported once, in the alias that won.
|
|
238
|
+
#
|
|
239
|
+
# ### The order is re-derivable
|
|
240
|
+
#
|
|
241
|
+
# Score descending, and equal scores by list and then entity id. Ties are
|
|
242
|
+
# not a corner case on this corpus -- a query matching two records of the
|
|
243
|
+
# same name scores them identically -- and which one is listed first has
|
|
244
|
+
# to be the same answer in a year's time.
|
|
245
|
+
sig { params(query: T.untyped, overrides: T.untyped).returns(T::Array[MatchResult]) }
|
|
246
|
+
def screen(query = nil, **overrides)
|
|
247
|
+
run(Query.build(query, **overrides), Time.now.utc)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A book of names against one list version:
|
|
251
|
+
#
|
|
252
|
+
# matcher.screen_all(["Bosco Ntaganda", "Gazprom"], threshold: 80)
|
|
253
|
+
# matcher.screen_all(customers.map { |c| { name: c.name, dob: c.born_on } })
|
|
254
|
+
#
|
|
255
|
+
# Index-aligned: the nth element is the nth query's results, and it is an
|
|
256
|
+
# empty array for a name that hit nothing. Deliberately not keyed by name
|
|
257
|
+
# -- a batch of customers contains the same name twice often enough, and a
|
|
258
|
+
# Hash would silently screen one of them and report both.
|
|
259
|
+
#
|
|
260
|
+
# Every result in the batch carries one `screened_at`, because a batch is
|
|
261
|
+
# one screening run: a rescreening of a customer book against a new list
|
|
262
|
+
# version is a single event in an audit trail, not ten thousand of them a
|
|
263
|
+
# microsecond apart.
|
|
264
|
+
sig { params(queries: T.untyped, overrides: T.untyped).returns(T::Array[T::Array[MatchResult]]) }
|
|
265
|
+
def screen_all(queries, **overrides)
|
|
266
|
+
raise QueryError, "screen_all takes an Array of queries, got #{queries.class}" unless queries.is_a?(Array)
|
|
267
|
+
|
|
268
|
+
screened_at = Time.now.utc
|
|
269
|
+
queries.map { |query| run(Query.build(query, **overrides), screened_at) }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The lists this matcher screens against, sorted.
|
|
273
|
+
sig { returns(T::Array[Symbol]) }
|
|
274
|
+
def sources = snapshots.keys.sort
|
|
275
|
+
|
|
276
|
+
# The checksum of the list version this matcher holds for a source, or nil
|
|
277
|
+
# for one it does not. Named for the Backend contract (#56), where every
|
|
278
|
+
# backend has to be able to answer it or reproducibility breaks at the
|
|
279
|
+
# seam.
|
|
280
|
+
sig { params(source: T.untyped).returns(T.nilable(String)) }
|
|
281
|
+
def snapshot_id(source) = snapshots[Sources::Definition.key!(source)]
|
|
282
|
+
|
|
283
|
+
# Whether the list this matcher holds for a source was attested. What every
|
|
284
|
+
# result off that list records.
|
|
285
|
+
sig { params(source: T.untyped).returns(T::Boolean) }
|
|
286
|
+
def verified?(source) = verified.include?(Sources::Definition.key!(source))
|
|
287
|
+
|
|
288
|
+
# How many names are screened against. Names rather than entities -- see
|
|
289
|
+
# Index#size.
|
|
290
|
+
sig { returns(Integer) }
|
|
291
|
+
def size = index.size
|
|
292
|
+
|
|
293
|
+
sig { returns(String) }
|
|
294
|
+
def inspect = "#<#{self.class} #{size} names from #{sources.join(", ")}>"
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
# One query, at one instant. Everything `screen` and `screen_all` share.
|
|
299
|
+
sig { params(query: Query, screened_at: Time).returns(T::Array[MatchResult]) }
|
|
300
|
+
def run(query, screened_at)
|
|
301
|
+
held!(query)
|
|
302
|
+
# The same stamp on every result the run produces: one query, one set of
|
|
303
|
+
# weights, one instant, one backend. Only the snapshot checksum varies,
|
|
304
|
+
# and only because a run may cover several lists.
|
|
305
|
+
stamp = { query: query, weights: weights, backend: backend, screened_at: screened_at }
|
|
306
|
+
scored(query)
|
|
307
|
+
.sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
|
|
308
|
+
.first(query.limit)
|
|
309
|
+
.map do |result|
|
|
310
|
+
MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
|
|
311
|
+
verified: verified.include?(result.source), **stamp)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Every entity the index retrieved, scored once.
|
|
316
|
+
#
|
|
317
|
+
# An entity reached through two of its names is one hit and not two, and
|
|
318
|
+
# the scorer already takes the maximum over an entity's names, so the
|
|
319
|
+
# second candidate would recompute the answer the first one gave. Rejected
|
|
320
|
+
# entities are remembered as nil for the same reason: a common given name
|
|
321
|
+
# retrieves the same record under several spellings, and rescoring one
|
|
322
|
+
# that has already failed the threshold is the most expensive way to
|
|
323
|
+
# arrive at the same no.
|
|
324
|
+
sig { params(query: Query).returns(T::Array[Scorer::Result]) }
|
|
325
|
+
def scored(query)
|
|
326
|
+
seen = T.let({}, T::Hash[[Symbol, String], T.nilable(Scorer::Result)])
|
|
327
|
+
index.candidates(query.form, limit: candidate_limit, sources: query.sources).each do |candidate|
|
|
328
|
+
# Keyed by list as well as id, because the same person really is two
|
|
329
|
+
# records when two governments list them, and both belong in a report.
|
|
330
|
+
key = [candidate.source, candidate.entity.id]
|
|
331
|
+
next if seen.key?(key)
|
|
332
|
+
|
|
333
|
+
seen[key] = Scorer.call(query.subject, candidate, weights: weights, threshold: query.threshold)
|
|
334
|
+
end
|
|
335
|
+
seen.values.compact
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A query may only name lists this matcher actually holds. Screening
|
|
339
|
+
# against a list that is not here returns fewer hits and no signal that it
|
|
340
|
+
# did, which reads exactly like a clean report.
|
|
341
|
+
sig { params(query: Query).void }
|
|
342
|
+
def held!(query)
|
|
343
|
+
missing = (query.sources || []) - snapshots.keys
|
|
344
|
+
return if missing.empty?
|
|
345
|
+
|
|
346
|
+
raise Storage::MissingSnapshot,
|
|
347
|
+
"this matcher does not hold #{missing.join(", ")}. It screens #{sources.join(", ")} -- " \
|
|
348
|
+
"rebuild it over the lists you meant, and sync any that have never been fetched"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
sig { params(value: T.untyped).returns(T::Hash[Symbol, String]) }
|
|
352
|
+
def snapshots!(value)
|
|
353
|
+
checksums = value.to_h { |source, checksum| [Sources::Definition.key!(source), -checksum.to_s] }
|
|
354
|
+
raise NotSynced, "a matcher needs at least one list to screen against" if checksums.empty?
|
|
355
|
+
|
|
356
|
+
blank = checksums.select { |_, checksum| checksum.empty? }.keys
|
|
357
|
+
raise InvalidArgument, "no snapshot checksum for #{blank.join(", ")}" if blank.any?
|
|
358
|
+
|
|
359
|
+
checksums.freeze
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Only lists this matcher actually holds, sorted. A source named here that
|
|
363
|
+
# is not in `snapshots` is a caller building a stamp out of a list nothing
|
|
364
|
+
# was screened against.
|
|
365
|
+
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
366
|
+
def verified!(value)
|
|
367
|
+
keys = Array(value).map { |source| Sources::Definition.key!(source) }.uniq.sort
|
|
368
|
+
missing = keys - snapshots.keys
|
|
369
|
+
raise InvalidArgument, "verified names #{missing.join(", ")}, which this matcher does not hold" if missing.any?
|
|
370
|
+
|
|
371
|
+
keys.freeze
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
375
|
+
def candidate_limit!(value)
|
|
376
|
+
return ActiveSanction.config.candidate_limit if value.nil?
|
|
377
|
+
|
|
378
|
+
integer = begin
|
|
379
|
+
Integer(value)
|
|
380
|
+
rescue TypeError, ArgumentError
|
|
381
|
+
raise InvalidArgument, "candidate_limit must be a whole number of names, got #{value.inspect}"
|
|
382
|
+
end
|
|
383
|
+
raise InvalidArgument, "candidate_limit must be at least 1, got #{integer}" unless integer.positive?
|
|
384
|
+
|
|
385
|
+
integer
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
# A single name variant attached to an entity. Entities routinely carry more
|
|
8
|
+
# aliases than primary names -- OFAC ships 19,321 primary names against
|
|
9
|
+
# 20,147 aliases -- so an alias is the common case, not the exception.
|
|
10
|
+
#
|
|
11
|
+
# ActiveSanction::Name.new(
|
|
12
|
+
# value: "AERO-CARIBBEAN",
|
|
13
|
+
# kind: :aka,
|
|
14
|
+
# quality: :good,
|
|
15
|
+
# script: :latin
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# A pure data holder: it stores what the publisher said and nothing more. The
|
|
19
|
+
# normalized and phonetic forms a search actually compares against are built
|
|
20
|
+
# by the normalizer (#26) and the indexer (#31), which need the untouched
|
|
21
|
+
# original to work from.
|
|
22
|
+
#
|
|
23
|
+
# Instances are frozen on construction and compare by value.
|
|
24
|
+
class Name
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
# OFAC's ALT.CSV supplies `alt_type` as aka / fka / nka directly, and the
|
|
28
|
+
# distinction matters downstream: a former name (fka) is still a real hit,
|
|
29
|
+
# but ranking it identically to a currently-used one costs precision.
|
|
30
|
+
KINDS = T.let(%i[primary aka fka nka].freeze, T::Array[Symbol])
|
|
31
|
+
|
|
32
|
+
# The UN consolidated list grades each alias Good or Low. A Low alias is a
|
|
33
|
+
# weaker signal -- the scorer (#32) penalizes it -- so the grade has to
|
|
34
|
+
# survive parsing rather than being flattened away here. Every other source
|
|
35
|
+
# publishes no grade at all, which is `nil`: unstated, not good.
|
|
36
|
+
QUALITIES = T.let(%i[good low].freeze, T::Array[Symbol])
|
|
37
|
+
|
|
38
|
+
# The writing system `value` is published in, which is what tells the
|
|
39
|
+
# normalizer (#26) which transliteration path to take -- a Cyrillic name
|
|
40
|
+
# folded by the Latin rules comes out as noise. Adapters map their source's
|
|
41
|
+
# own vocabulary onto these: OFAC labels some names by language rather than
|
|
42
|
+
# script, so "Farsi" arrives here as :arabic.
|
|
43
|
+
#
|
|
44
|
+
# Closed, so a typo is caught at the boundary instead of quietly minting a
|
|
45
|
+
# script nothing downstream handles. It is sized to what the lists actually
|
|
46
|
+
# publish rather than to all ~200 of ISO 15924; a source shipping one we
|
|
47
|
+
# have not seen is a one-line addition here, which the raised message asks
|
|
48
|
+
# for by name.
|
|
49
|
+
SCRIPTS = T.let(%i[
|
|
50
|
+
latin cyrillic arabic hebrew greek han kana hangul
|
|
51
|
+
thai devanagari bengali tamil myanmar khmer armenian georgian ethiopic syriac
|
|
52
|
+
].freeze, T::Array[Symbol])
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
ENUMS = T.let({ kind: KINDS, quality: QUALITIES, script: SCRIPTS }.freeze, T::Hash[Symbol, T::Array[Symbol]])
|
|
56
|
+
|
|
57
|
+
# Canonical member order. Snapshot (#8) checksums the serialized form, so
|
|
58
|
+
# #to_h must lay its keys out the same way every time.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
MEMBERS = T.let(%i[value kind quality script].freeze, T::Array[Symbol])
|
|
62
|
+
|
|
63
|
+
# The publisher's own string, stripped of surrounding whitespace and
|
|
64
|
+
# otherwise untouched.
|
|
65
|
+
sig { returns(String) }
|
|
66
|
+
attr_reader :value
|
|
67
|
+
|
|
68
|
+
sig { returns(Symbol) }
|
|
69
|
+
attr_reader :kind
|
|
70
|
+
|
|
71
|
+
# nil where the source publishes no grade, which is every source but the
|
|
72
|
+
# UN. See #low_quality?: unstated is not low.
|
|
73
|
+
sig { returns(T.nilable(Symbol)) }
|
|
74
|
+
attr_reader :quality
|
|
75
|
+
|
|
76
|
+
sig { returns(T.nilable(Symbol)) }
|
|
77
|
+
attr_reader :script
|
|
78
|
+
|
|
79
|
+
# Rebuilds a name from #to_h output. Accepts string keys and string values
|
|
80
|
+
# for the enum members, so a name that has been through JSON round-trips
|
|
81
|
+
# without a separate coercion step.
|
|
82
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
83
|
+
def self.from_h(hash)
|
|
84
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
85
|
+
unknown = attributes.keys - MEMBERS
|
|
86
|
+
raise InvalidArgument, "unknown Name attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
87
|
+
|
|
88
|
+
# `new(**hash)` past a required keyword parameter is one of the few
|
|
89
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
90
|
+
# arrives, which is where a bad round-trip is caught.
|
|
91
|
+
T.unsafe(self).new(**attributes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `kind` defaults to :primary because that is what a source with no alias
|
|
95
|
+
# data at all means: Canada publishes no aliases, so every Canadian name is
|
|
96
|
+
# a primary one.
|
|
97
|
+
#
|
|
98
|
+
# Untyped on purpose, and the same choice Entity makes: every one of these
|
|
99
|
+
# is the publisher's text arriving as whatever the parser made of it. The
|
|
100
|
+
# coercions below say what happens to it, in messages written for whoever
|
|
101
|
+
# has to fix the record.
|
|
102
|
+
sig do
|
|
103
|
+
params(value: T.untyped, kind: T.untyped, quality: T.untyped, script: T.untyped).void
|
|
104
|
+
end
|
|
105
|
+
def initialize(value:, kind: :primary, quality: nil, script: nil)
|
|
106
|
+
@value = T.let(value!(value), String)
|
|
107
|
+
@kind = T.let(enum!(:kind, kind), Symbol)
|
|
108
|
+
@quality = T.let(quality.nil? ? nil : enum!(:quality, quality), T.nilable(Symbol))
|
|
109
|
+
@script = T.let(script.nil? ? nil : enum!(:script, script), T.nilable(Symbol))
|
|
110
|
+
freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sig { returns(T::Boolean) }
|
|
114
|
+
def primary? = kind == :primary
|
|
115
|
+
|
|
116
|
+
# Every kind except :primary. Reads better at call sites than `!primary?`
|
|
117
|
+
# and keeps the definition of "alias" in one place if a kind is ever added.
|
|
118
|
+
sig { returns(T::Boolean) }
|
|
119
|
+
def alias? = !primary?
|
|
120
|
+
|
|
121
|
+
# nil quality is not low quality: only the UN grades aliases, so an ungraded
|
|
122
|
+
# name must not be penalized for a field its source never publishes.
|
|
123
|
+
# Compared by identity because symbols are interned and `quality` is
|
|
124
|
+
# nilable: `nil == :low` is a call on NilClass, which Sorbet will not make.
|
|
125
|
+
sig { returns(T::Boolean) }
|
|
126
|
+
def low_quality? = quality.equal?(:low)
|
|
127
|
+
|
|
128
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
129
|
+
def to_h
|
|
130
|
+
{ value: value, kind: kind, quality: quality, script: script }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
sig { returns(String) }
|
|
134
|
+
def to_s = value
|
|
135
|
+
|
|
136
|
+
# Class is part of the comparison to keep #== and #hash agreeing, which is
|
|
137
|
+
# what Hash and Set rely on.
|
|
138
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
139
|
+
def ==(other)
|
|
140
|
+
return false unless other.instance_of?(self.class)
|
|
141
|
+
|
|
142
|
+
to_h == other.to_h
|
|
143
|
+
end
|
|
144
|
+
alias eql? ==
|
|
145
|
+
|
|
146
|
+
sig { returns(Integer) }
|
|
147
|
+
def hash
|
|
148
|
+
[self.class, to_h].hash
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sig { returns(String) }
|
|
152
|
+
def inspect
|
|
153
|
+
"#<#{self.class} #{value.inspect} kind=#{kind.inspect}#{" quality=#{quality.inspect}" if quality}>"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Surrounding whitespace is stripped -- the delimited sources are full of it
|
|
159
|
+
# -- but nothing else is touched. Case, diacritics, punctuation and word
|
|
160
|
+
# order are all signal the matcher needs to see as published.
|
|
161
|
+
sig { params(value: T.untyped).returns(String) }
|
|
162
|
+
def value!(value)
|
|
163
|
+
string = value.to_s.strip
|
|
164
|
+
raise InvalidArgument, "value is required" if string.empty?
|
|
165
|
+
|
|
166
|
+
-string
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Case is folded before the lookup: the UN writes its grades as Good and
|
|
170
|
+
# Low, OFAC writes its alias types lowercase and its scripts capitalized,
|
|
171
|
+
# and no adapter should have to remember which. A wrong value still raises.
|
|
172
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
173
|
+
def enum!(member, value)
|
|
174
|
+
raise InvalidArgument, "#{member} is required" if value.to_s.empty?
|
|
175
|
+
|
|
176
|
+
symbol = value.to_s.downcase.to_sym
|
|
177
|
+
permitted = ENUMS.fetch(member)
|
|
178
|
+
return symbol if permitted.include?(symbol)
|
|
179
|
+
|
|
180
|
+
raise InvalidArgument, "unknown #{member} #{symbol.inspect}, expected one of #{permitted.join(", ")}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
class Normalizer
|
|
8
|
+
# A bounded memo of folded names, because the same string is normalized
|
|
9
|
+
# over and over: an index build (#31) folds every name once per index it
|
|
10
|
+
# feeds it to, an entity's aliases repeat across records, and a rescreening
|
|
11
|
+
# run (#60) folds the same book of subjects against every new snapshot.
|
|
12
|
+
# Folding is five passes over a string and a Unicode normalization, which
|
|
13
|
+
# is cheap once and worth not doing 200,000 times.
|
|
14
|
+
#
|
|
15
|
+
# Thread-safe, because the query path is shared: one web process screens on
|
|
16
|
+
# many threads against one index, and they all reach the same normalizer.
|
|
17
|
+
# The value being memoized is a pure function of the key, so the only thing
|
|
18
|
+
# the lock protects is the Hash's own consistency -- never the computation,
|
|
19
|
+
# which runs outside it. Two threads racing on a cold key both fold the
|
|
20
|
+
# string and store equal results, which costs one redundant fold and is
|
|
21
|
+
# much cheaper than serializing every normalization behind one mutex.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class Cache
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
# Sized to hold the whole searchable corpus, which is roughly 46,000 name
|
|
28
|
+
# strings across the launch lists, with room for the query names a
|
|
29
|
+
# long-running process accumulates beside them. At that ceiling the cache
|
|
30
|
+
# is on the order of 20 MB -- against an inverted index over the same
|
|
31
|
+
# corpus, which is considerably larger.
|
|
32
|
+
#
|
|
33
|
+
# A host that screens rarely and cares about resident memory can build
|
|
34
|
+
# its own `Normalizer.new(cache_limit:)` with a smaller one; nothing is
|
|
35
|
+
# lost but the memoization.
|
|
36
|
+
DEFAULT_LIMIT = T.let(50_000, Integer)
|
|
37
|
+
|
|
38
|
+
sig { returns(Integer) }
|
|
39
|
+
attr_reader :limit
|
|
40
|
+
|
|
41
|
+
sig { params(limit: Integer).void }
|
|
42
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
43
|
+
raise InvalidArgument, "limit must be at least 1, got #{limit}" unless limit.positive?
|
|
44
|
+
|
|
45
|
+
@limit = T.let(limit, Integer)
|
|
46
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
47
|
+
@entries = T.let({}, T::Hash[String, Form])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The memoized fold of `key`, computed by the block on a miss.
|
|
51
|
+
#
|
|
52
|
+
# A full cache is emptied rather than evicted from one entry at a time.
|
|
53
|
+
# An LRU would need a write on every read, which turns a hit -- the case
|
|
54
|
+
# this exists for -- into lock contention on the query path. The trade is
|
|
55
|
+
# that a process which does cross the ceiling occasionally refolds a warm
|
|
56
|
+
# set, costing microseconds; the case the cache is actually for is an
|
|
57
|
+
# index build, where the corpus fits under the ceiling and the clear
|
|
58
|
+
# never fires at all.
|
|
59
|
+
sig { params(key: String, block: T.proc.returns(Form)).returns(Form).checked(:tests) }
|
|
60
|
+
def fetch(key, &block)
|
|
61
|
+
cached = @mutex.synchronize { @entries[key] }
|
|
62
|
+
return cached if cached
|
|
63
|
+
|
|
64
|
+
form = block.call
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@entries.clear if @entries.size >= @limit
|
|
67
|
+
@entries[key] = form
|
|
68
|
+
end
|
|
69
|
+
form
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { returns(Integer) }
|
|
73
|
+
def size = @mutex.synchronize { @entries.size }
|
|
74
|
+
|
|
75
|
+
sig { void }
|
|
76
|
+
def clear
|
|
77
|
+
@mutex.synchronize { @entries.clear }
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Titles of respect and rank, stripped from individual names only.
|
|
2
|
+
#
|
|
3
|
+
# A publisher records the title it was told; a caller screening a customer
|
|
4
|
+
# almost never has one. "General Taganda" -- a UN alias for Bosco Ntaganda --
|
|
5
|
+
# should be reachable from "Taganda", and "Hajji Abdallah" from "Abdallah".
|
|
6
|
+
#
|
|
7
|
+
# The list is deliberately short. A title that is also a given name costs far
|
|
8
|
+
# more than it saves: `Amir` is a title in one record and a first name in the
|
|
9
|
+
# next, so it is absent here, and so is `Major`.
|
|
10
|
+
#
|
|
11
|
+
# Anything here that collides with particles.txt is discarded, not applied.
|
|
12
|
+
|
|
13
|
+
MR
|
|
14
|
+
MRS
|
|
15
|
+
MS
|
|
16
|
+
DR
|
|
17
|
+
PROF
|
|
18
|
+
SIR
|
|
19
|
+
|
|
20
|
+
HAJJI
|
|
21
|
+
HAJJ
|
|
22
|
+
SHAYKH
|
|
23
|
+
SHEIKH
|
|
24
|
+
SHAIKH
|
|
25
|
+
MULLAH
|
|
26
|
+
MAULAVI
|
|
27
|
+
|
|
28
|
+
GENERAL
|
|
29
|
+
COLONEL
|
|
30
|
+
CAPT
|