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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. 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