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,279 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/normalizer/dictionary/stoplist"
7
+
8
+ module ActiveSanction
9
+ class Normalizer
10
+ # A name in both of the forms a screening decision needs: the string the
11
+ # publisher wrote, and the folded string a comparison actually runs
12
+ # against.
13
+ #
14
+ # form = ActiveSanction::Normalizer.call("O'Brien, Seán")
15
+ # form.original # => "O'Brien, Seán"
16
+ # form.value # => "o brien sean"
17
+ # form.tokens # => ["o", "brien", "sean"]
18
+ #
19
+ # org = ActiveSanction::Normalizer.call("Rosneft Oil Company", type: :organization)
20
+ # org.value # => "rosneft oil"
21
+ # org.type # => :organization
22
+ #
23
+ # Both halves travel together because both are needed at different ends of
24
+ # the same query. The scorers (#28, #29) compare `value`; the index (#31)
25
+ # keys on `tokens`; and what a compliance user reads in a hit is
26
+ # `original`, in the government's own capitalization and punctuation. A
27
+ # report that quotes the folded string instead is quoting this library
28
+ # rather than the list, which is not something anyone can take to an
29
+ # examiner.
30
+ #
31
+ # Instances are frozen on construction and compare by value.
32
+ #
33
+ # ### The pipeline
34
+ #
35
+ # Five stages, applied in this order to indexed names and query names
36
+ # alike -- see Normalizer for why that sameness is the whole point -- and
37
+ # a sixth that runs only for a caller who said what kind of entity the
38
+ # name belongs to:
39
+ #
40
+ # 1. **Unicode NFKD.** Decomposes `é` into `e` + combining acute, and folds
41
+ # the compatibility forms a publisher's export tooling emits: full-width
42
+ # `ABC` becomes `ABC`, `Ⅳ` becomes `IV`, `①` becomes `1`, and the
43
+ # no-break spaces scattered through the delimited lists become ordinary
44
+ # ones.
45
+ # 2. **Strip combining marks.** What stage 1 separated is dropped, so
46
+ # `Bélarus` is `belarus` and `Müller` is `muller`. Arabic gets the same
47
+ # treatment for free and wants it: the harakat are optional in writing,
48
+ # so one publisher's `مُحَمَّد` and another's `محمد` have to fold together, and
49
+ # `أ` decomposes to a bare alef rather than staying a third spelling of
50
+ # the same first letter.
51
+ # 3. **Casefold**, Unicode-aware: `String#downcase(:fold)` rather than
52
+ # `String#downcase`, which is what turns `Straße` into `strasse` instead
53
+ # of leaving a `ß` that no query will ever be typed with.
54
+ # 3b. **Transliterate the letters NFKD cannot help with** -- see
55
+ # TRANSLITERATIONS.
56
+ # 4. **Punctuation to spaces**, not to nothing: `Al-Qaida` is `al qaida`
57
+ # and `O'Brien` is `o brien`. Splitting is the conservative direction.
58
+ # A hyphen and a space are written interchangeably across these lists,
59
+ # so joining `Al-Qaida` into `alqaida` would make it unreachable from
60
+ # the `al qaida` a caller types, while splitting it leaves both sides as
61
+ # the same two tokens for the token ratios (#29) to work on.
62
+ # 5. **Collapse whitespace and strip**, which is what `tokens` is: the
63
+ # folded string split on whitespace, with `value` its single-spaced
64
+ # join.
65
+ # 6. **Drop the tokens that carry no identifying information**, given a
66
+ # Stoplist: `LTD` and `COMPANY` from an organization, `SHAYKH` from a
67
+ # person, nothing at all from either without one. Stage 6 is the only
68
+ # one that depends on something outside the string, which is why it
69
+ # arrives as an argument -- see Dictionary for what is on the lists and
70
+ # for the particles they may never touch.
71
+ #
72
+ # A name that folds away entirely keeps its unstripped tokens. An
73
+ # organization called "The Company" is a poor name to screen on and a
74
+ # worse one to index as the empty string, which matches everything or
75
+ # nothing depending on which scorer sees it first.
76
+ #
77
+ # ### What it deliberately does not do
78
+ #
79
+ # **Non-Latin script is not transliterated.** Cyrillic, Arabic, Han, Kana
80
+ # and Hangul come out of here casefolded and stripped of marks, in their
81
+ # own script. `Путин` does not become `putin`, so a Cyrillic name matches a
82
+ # Cyrillic query and nothing else. That is a real recall limitation, and it
83
+ # is stated rather than papered over.
84
+ #
85
+ # What makes it survivable is that these lists publish a non-Latin name as
86
+ # an additional variant rather than instead of a Latin one -- the UN's
87
+ # ORIGINAL_SCRIPT aliases and Canada's Cyrillic ones both sit on records
88
+ # that carry a romanized name too, which is the one an English-language
89
+ # query finds. Romanization itself is a per-script problem with several
90
+ # competing standards for Cyrillic alone, and guessing at it costs
91
+ # precision everywhere, so v1 does not. Double Metaphone (#30) covers the
92
+ # case this actually leaves open, which is one name romanized two ways.
93
+ #
94
+ # One consequence worth knowing: NFKD decomposes Hangul syllables into
95
+ # jamo, so `김정은` folds to its letters rather than its syllable blocks.
96
+ # Nothing downstream cares -- both sides of a comparison are folded the
97
+ # same way -- but the value is not the string a Korean reader would type.
98
+ class Form
99
+ extend T::Sig
100
+
101
+ # Nonspacing and enclosing marks: the diacritics stage 1 separated from
102
+ # their letters. Spacing marks (`Mc`) are deliberately left alone --
103
+ # those are the Indic vowel signs, which are letters in every sense that
104
+ # matters here, and dropping them would fold `का` and `कि` onto the same
105
+ # consonant.
106
+ #
107
+ # @api private
108
+ MARKS = T.let(/[\p{Mn}\p{Me}]/, Regexp)
109
+
110
+ # The Latin letters NFKD leaves untouched, because they are letters in
111
+ # their own right rather than a letter wearing a mark: nothing decomposes
112
+ # `ø` into an `o`. Without this table `Bjørn` and `Bjorn` are two
113
+ # different names, which is the same false negative diacritic stripping
114
+ # exists to prevent -- it just happens to affect Scandinavian, Polish,
115
+ # Turkish, Icelandic, Vietnamese and Azerbaijani names rather than French
116
+ # ones.
117
+ #
118
+ # Applied after casefolding, so the table only has to carry lowercase
119
+ # keys. `ß` is absent because casefolding already turns it into `ss`.
120
+ #
121
+ # @api private
122
+ TRANSLITERATIONS = T.let(
123
+ {
124
+ "æ" => "ae", "œ" => "oe", "ø" => "o", "ð" => "d", "þ" => "th",
125
+ "đ" => "d", "ħ" => "h", "ı" => "i", "ł" => "l", "ŀ" => "l",
126
+ "ŋ" => "n", "ŧ" => "t", "ĸ" => "k", "ə" => "e"
127
+ }.freeze,
128
+ T::Hash[String, String]
129
+ )
130
+
131
+ # @api private
132
+ TRANSLITERABLE = T.let(Regexp.union(TRANSLITERATIONS.keys), Regexp)
133
+
134
+ # Everything that is not a letter, a digit or whitespace, which covers
135
+ # the punctuation and the symbols in one rule and needs no list of
136
+ # dashes and quotation marks to be kept in step with reality.
137
+ #
138
+ # Plus the characters Unicode calls letters and a transliterator uses as
139
+ # punctuation: the spacing modifier letters, U+02B0 to U+02FF (`ʻ ʼ ʹ ʾ
140
+ # ʿ`), and the two glottal stop letters (`ʔ ʕ`). The UN list writes
141
+ # `Sanʻa` and `Qurʼan` with these where OFAC writes a plain apostrophe or
142
+ # nothing at all, and a mark that survives here is a token no query will
143
+ # ever be typed with.
144
+ #
145
+ # @api private
146
+ PUNCTUATION = T.let(/[^[:alnum:][:space:]]|[ʔʕʰ-˿]/, Regexp)
147
+
148
+ # The publisher's string, untouched. This is what a hit is reported in.
149
+ sig { returns(String).checked(:tests) }
150
+ attr_reader :original
151
+
152
+ # The folded form: lowercase, unmarked, punctuation-free, single-spaced.
153
+ # Empty when the original carried nothing a comparison can use -- see
154
+ # #empty?.
155
+ sig { returns(String).checked(:tests) }
156
+ attr_reader :value
157
+
158
+ # `value` split on whitespace. Frozen, and the array the token ratios
159
+ # (#29) and the inverted index (#31) read rather than splitting again per
160
+ # comparison.
161
+ sig { returns(T::Array[String]).checked(:tests) }
162
+ attr_reader :tokens
163
+
164
+ # The entity type this name was folded for, or nil when the caller did
165
+ # not say. It is what decides stage 6, and it travels with the form
166
+ # because two folds of the same string under different types are two
167
+ # different answers -- which is also why `value` is part of #==.
168
+ sig { returns(T.nilable(Symbol)).checked(:tests) }
169
+ attr_reader :type
170
+
171
+ # Untyped for the reason the rest of the model is: what arrives here is a
172
+ # publisher's text as whatever the parser made of it. Anything that
173
+ # responds to `to_s` works, which includes Name -- `Name#to_s` is its
174
+ # value -- so an indexer can hand over the object it already has.
175
+ #
176
+ # `stoplist` is stage 6, and comes from a Dictionary rather than from
177
+ # here: which tokens carry no information is a property of the entity
178
+ # type and of the lists in force, neither of which the string knows.
179
+ #
180
+ # Building a Form directly skips the cache; Normalizer.call is the entry
181
+ # point everything in the library goes through, and the one that resolves
182
+ # a type into the stoplist for it.
183
+ sig { params(original: T.untyped, stoplist: T.nilable(Dictionary::Stoplist)).void.checked(:tests) }
184
+ def initialize(original, stoplist: nil)
185
+ @original = T.let(-original.to_s, String)
186
+ @type = T.let(stoplist&.type, T.nilable(Symbol))
187
+ @tokens = T.let(fold(@original, stoplist), T::Array[String])
188
+ @value = T.let(-@tokens.join(" "), String)
189
+ freeze
190
+ end
191
+
192
+ # True when nothing survived the fold: a name of `"---"`, of punctuation,
193
+ # of an emoji, or of whitespace alone. It happens in real data, and it
194
+ # matters because such a name cannot be indexed and cannot be scored --
195
+ # every comparison against it is meaningless rather than merely bad. The
196
+ # index (#31) skips these; the alternative is a record that matches
197
+ # everything or nothing depending on which scorer sees it first.
198
+ sig { returns(T::Boolean).checked(:tests) }
199
+ def empty? = value.empty?
200
+
201
+ sig { returns(String).checked(:tests) }
202
+ def to_s = value
203
+
204
+ # Class is part of the comparison to keep #== and #hash agreeing, which
205
+ # is what Hash and Set rely on -- and the index is built out of both.
206
+ #
207
+ # Two forms are equal when they came from the same original and folded
208
+ # to the same value. The second half is not redundant now that stage 6
209
+ # exists: `value` is a pure function of the original *and* the stoplist,
210
+ # so "Rosneft Oil Company" folded as an organization and the same string
211
+ # folded as nothing in particular are two different answers rather than
212
+ # one.
213
+ #
214
+ # Note that this makes two differently-written names that fold to the
215
+ # same string unequal *as forms* while comparing as identical *for
216
+ # matching*, which is the distinction the whole pipeline rests on.
217
+ sig { params(other: T.untyped).returns(T::Boolean).checked(:tests) }
218
+ def ==(other)
219
+ return false unless other.instance_of?(self.class)
220
+
221
+ original == other.original && value == other.value
222
+ end
223
+ alias eql? ==
224
+
225
+ sig { returns(Integer).checked(:tests) }
226
+ def hash
227
+ [self.class, original, value].hash
228
+ end
229
+
230
+ sig { returns(String) }
231
+ def inspect
232
+ "#<#{self.class} #{original.inspect} => #{value.inspect}>"
233
+ end
234
+
235
+ private
236
+
237
+ # The stages, in the order they have to run in: marks cannot be stripped
238
+ # before NFKD has separated them, the transliteration table only carries
239
+ # the lowercase keys casefolding produces, and the stoplist is written in
240
+ # the tokens the first five stages produce.
241
+ #
242
+ # Stripping every token is treated as stripping none. A name of nothing
243
+ # but legal forms is rare and real -- an organization called "The
244
+ # Company", a vessel whose only alias is its owner's suffix -- and the
245
+ # empty string is the one fold that cannot be scored at all.
246
+ sig do
247
+ params(string: String, stoplist: T.nilable(Dictionary::Stoplist))
248
+ .returns(T::Array[String]).checked(:tests)
249
+ end
250
+ def fold(string, stoplist)
251
+ decomposed = utf8(string).unicode_normalize(:nfkd).gsub(MARKS, "")
252
+ folded = decomposed.downcase(:fold).gsub(TRANSLITERABLE, TRANSLITERATIONS)
253
+ # `&:-@` is String#-@, the deduplicating freeze: 46,000 names share
254
+ # a few thousand distinct tokens between them, and the index holds
255
+ # onto every one of them.
256
+ tokens = folded.gsub(PUNCTUATION, " ").split.map(&:-@).freeze
257
+ return tokens if stoplist.nil?
258
+
259
+ kept = stoplist.reject(tokens)
260
+ kept.empty? ? tokens : kept.freeze
261
+ end
262
+
263
+ # `unicode_normalize` raises on a string that is not valid UTF-8, and one
264
+ # record's stray byte is not worth failing an entire index build over --
265
+ # a list that will not build is a list nobody is screened against. The
266
+ # bad bytes become U+FFFD, which stage 4 turns into a space.
267
+ #
268
+ # Only the folded form is repaired. `original` keeps whatever arrived,
269
+ # because the point of keeping it is to show a compliance user exactly
270
+ # what the publisher's file said.
271
+ sig { params(string: String).returns(String).checked(:tests) }
272
+ def utf8(string)
273
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
274
+
275
+ string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,137 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/normalizer/cache"
7
+ require "active_sanction/normalizer/dictionary"
8
+ require "active_sanction/normalizer/form"
9
+
10
+ module ActiveSanction
11
+ # The one place a name is folded into the form a comparison runs against.
12
+ #
13
+ # form = ActiveSanction::Normalizer.call("Bélarus")
14
+ # form.value # => "belarus"
15
+ # form.original # => "Bélarus"
16
+ #
17
+ # ActiveSanction::Normalizer.call("CO., LTD.").tokens # => ["co", "ltd"]
18
+ # ActiveSanction::Normalizer.call(name) # a Name works too
19
+ #
20
+ # ActiveSanction::Normalizer.call("PJSC Gazprom", type: :organization).value
21
+ # # => "gazprom"
22
+ #
23
+ # Stage 1 of the matching pipeline: everything a query is compared against
24
+ # has been through here, and so has the query. Form documents what the fold
25
+ # does and why each stage is there; this class is the entry point and the
26
+ # cache in front of it.
27
+ #
28
+ # ### Why one entry point rather than a method on each side
29
+ #
30
+ # Because a matcher whose index and query fold differently does not fail --
31
+ # it silently stops matching, on exactly the records the difference touches.
32
+ # If the indexer strips `'` and the query path does not, `O'Brien` is
33
+ # unreachable from `O'Brien`, the suite still passes, and the symptom is a
34
+ # sanctioned person reported clean. That is the most expensive bug this
35
+ # library can have and it is invisible from either side alone, so there is
36
+ # one code path and both sides call it. `Normalizer.call` is that path.
37
+ #
38
+ # It also means normalization is a versioned decision. Changing anything in
39
+ # Form changes every folded string in the library at once, which is what a
40
+ # stored index (#31) has to be rebuilt against and what a screening decision
41
+ # recorded under an older gem was made under -- see MatchResult's
42
+ # reproducibility stamp (#33).
43
+ #
44
+ # ### Instances
45
+ #
46
+ # `Normalizer.call` runs against DEFAULT, a process-wide instance whose cache
47
+ # is shared and internally synchronized. An instance exists as a seam rather
48
+ # than for configuration: a caller that wants its own cache -- a smaller one,
49
+ # or one it can discard after a batch -- builds `Normalizer.new`, and the
50
+ # fold it gets is identical.
51
+ #
52
+ # normalizer = ActiveSanction::Normalizer.new(cache_limit: 1_000)
53
+ # normalizer.call("Al-Qaida").value # => "al qaida"
54
+ #
55
+ # ### The dictionaries
56
+ #
57
+ # `type:` is what turns on stage 6, the pass that drops the tokens carrying
58
+ # no identifying information: `LTD` and `COMPANY` from an organization,
59
+ # `SHAYKH` from a person. It attaches here rather than to Form because the
60
+ # lists apply per entity type and are configurable, neither of which a string
61
+ # knows anything about; Dictionary is what is on them and why.
62
+ #
63
+ # Passing no type is not a lesser answer, it is a different question:
64
+ # "AERO-CARIBBEAN" as a bare string folds to `aero caribbean` whatever a
65
+ # dictionary says. A caller that has an Entity in hand should pass
66
+ # `entity.type`, and both sides of a comparison have to pass the same one --
67
+ # a query folded as an organization against an index folded as nothing is
68
+ # the same silent mismatch this class exists to prevent, one stage further
69
+ # down.
70
+ #
71
+ # A host's own lists reach the process-wide instance through configuration:
72
+ #
73
+ # ActiveSanction.configure do |c|
74
+ # c.normalizer_dictionary = { legal_forms: %w[OYJ TBK] }
75
+ # end
76
+ #
77
+ # An instance can pin one instead -- `Normalizer.new(dictionary:)` -- which
78
+ # is what makes a fold reproducible against a dictionary that is not the one
79
+ # the host configured.
80
+ class Normalizer
81
+ extend T::Sig
82
+
83
+ sig { returns(Cache) }
84
+ attr_reader :cache
85
+
86
+ sig { params(cache_limit: Integer, dictionary: T.nilable(Dictionary)).void }
87
+ def initialize(cache_limit: Cache::DEFAULT_LIMIT, dictionary: nil)
88
+ @cache = T.let(Cache.new(limit: cache_limit), Cache)
89
+ @dictionary = T.let(dictionary, T.nilable(Dictionary))
90
+ end
91
+
92
+ # The pinned dictionary, or the configured one. Resolved per call rather
93
+ # than captured at construction because DEFAULT is built at load, which is
94
+ # before an application's initializer has run.
95
+ sig { returns(Dictionary) }
96
+ def dictionary = @dictionary || ActiveSanction.config.normalizer_dictionary
97
+
98
+ # The folded form of anything that responds to `to_s`, which is a String or
99
+ # a Name, optionally for a given entity type -- `:individual`,
100
+ # `:organization`, `:vessel`, `:aircraft`, or none.
101
+ #
102
+ # Idempotent in the sense the acceptance criterion asks for --
103
+ # `call(call(x).value, type:).value == call(x, type:).value` -- because the
104
+ # fold's output is already lowercase, unmarked, punctuation-free and
105
+ # single-spaced, and a stripped token cannot come back to be stripped
106
+ # again.
107
+ sig { params(value: T.untyped, type: T.nilable(Symbol)).returns(Form).checked(:tests) }
108
+ def call(value, type: nil)
109
+ string = value.to_s
110
+ stoplist = dictionary.stoplist(type)
111
+ return cache.fetch(string) { Form.new(string) } if stoplist.nil?
112
+
113
+ # The type and the lists in force are both part of the answer, so both
114
+ # are part of the key. The leading NUL is what keeps a composite key from
115
+ # colliding with the bare string of an untyped call; no name a publisher
116
+ # writes begins with one.
117
+ cache.fetch("\u0000#{stoplist.key}\u0000#{string}") { Form.new(string, stoplist: stoplist) }
118
+ end
119
+
120
+ class << self
121
+ extend T::Sig
122
+
123
+ # Delegates to DEFAULT. This is the call site everything in the library
124
+ # uses; see the class comment for why there is only one.
125
+ sig { params(value: T.untyped, type: T.nilable(Symbol)).returns(Form).checked(:tests) }
126
+ def call(value, type: nil) = DEFAULT.call(value, type: type)
127
+ end
128
+
129
+ # Built at load rather than memoized on first use, so nothing has to
130
+ # synchronize its construction. It holds a cache and a mutex, and reads its
131
+ # dictionary from the configuration on each call rather than holding one,
132
+ # which is why one process-wide instance is enough.
133
+ #
134
+ # @api private
135
+ DEFAULT = T.let(new, Normalizer)
136
+ end
137
+ end
@@ -0,0 +1,274 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+
8
+ module ActiveSanction
9
+ module Parsers
10
+ # An assertion about what one column of a positional file contains,
11
+ # measured over the whole file.
12
+ #
13
+ # ENT_NUM = ColumnShape.new(name: :ent_num, matches: /\A\d+\z/, at_least: 0.99)
14
+ #
15
+ # tally = ENT_NUM.tally(rows.map { |row| row[:ent_num] })
16
+ # tally.ok? # => false
17
+ # tally.ratio # => 0.0
18
+ # tally.sample # => ["AEROCARIBBEAN AIRLINES", "AEROTAXI EJECUTIVO"]
19
+ # tally.to_s # => "ent_num numeric on 0.0% of 19321 rows (expected 99%)"
20
+ #
21
+ # ### Why a declared width is not enough
22
+ #
23
+ # A file that names its own columns cannot have them quietly swapped: the
24
+ # header moves with the data and an adapter reading `sdn_type` still gets
25
+ # the type. A headerless file has no such protection, and OFAC ships three
26
+ # of them. Declaring the column names pins the *width*, so a column
27
+ # inserted upstream arrives as a wrong-width row and every row says so --
28
+ # but a column *reordered* upstream keeps the width, parses cleanly, and
29
+ # produces 19,321 entities built from shifted fields. Nothing raises,
30
+ # nothing warns, and the list means something different.
31
+ #
32
+ # So the shape of the values is asserted separately from the shape of the
33
+ # row. `ent_num` is a number on essentially every row of OFAC's file, and
34
+ # a version of that file where it is a company name is not a version this
35
+ # library should screen against.
36
+ #
37
+ # ### `at_least`, rather than "every row"
38
+ #
39
+ # These are published files, not validated ones. A single row where a
40
+ # publisher typed a letter into a numeric column is a curiosity; a file
41
+ # where a third of them are is a format change. The threshold is what
42
+ # separates the two, and it defaults to 99% -- high enough that a real
43
+ # swap cannot hide under it, loose enough that one bad row does not stop a
44
+ # sync being diagnosed as healthy.
45
+ #
46
+ # Blank values are not counted at all. A column the publisher leaves empty
47
+ # is saying nothing about its shape, and OFAC leaves most of its columns
48
+ # empty most of the time -- `-0- ` roughly a quarter of a million times
49
+ # across the six files. Counting those as failures would make every
50
+ # assertion about an optional column fail on the day it was written.
51
+ #
52
+ # Instances are frozen on construction.
53
+ class ColumnShape
54
+ extend T::Sig
55
+
56
+ # @api private
57
+ DEFAULT_AT_LEAST = T.let(0.99, Float)
58
+
59
+ # Unmatched values kept as evidence. Enough to recognize what is in the
60
+ # column instead, and few enough that a whole shifted file does not
61
+ # arrive in a report someone has to read.
62
+ #
63
+ # @api private
64
+ SAMPLE_SIZE = T.let(3, Integer)
65
+
66
+ sig { returns(Symbol) }
67
+ attr_reader :name
68
+
69
+ # The share of non-blank values that must satisfy the rule.
70
+ sig { returns(Float) }
71
+ attr_reader :at_least
72
+
73
+ # What the column is supposed to hold, in words, for the message a
74
+ # failure prints: "numeric", "a known SDN_Type".
75
+ sig { returns(String) }
76
+ attr_reader :description
77
+
78
+ # One of `matches:` (a Regexp), `allowing:` (the values the column may
79
+ # hold, compared case-insensitively after stripping), or `satisfying:`
80
+ # (a callable taking the value and returning truthy). `description:`
81
+ # names the expectation in the failure message; the first two derive a
82
+ # readable one when it is not given.
83
+ sig do
84
+ params(name: T.untyped, matches: T.untyped, allowing: T.untyped, satisfying: T.untyped,
85
+ at_least: T.untyped, description: T.untyped).void
86
+ end
87
+ def initialize(name:, matches: nil, allowing: nil, satisfying: nil, at_least: DEFAULT_AT_LEAST,
88
+ description: nil)
89
+ @name = T.let(name!(name), Symbol)
90
+ @allowed = T.let(allowing.nil? ? nil : allowed!(allowing), T.nilable(T::Array[String]))
91
+ @rule = T.let(rule!(matches, satisfying), T.nilable(T.proc.params(value: String).returns(T.untyped)))
92
+ @at_least = T.let(at_least!(at_least), Float)
93
+ @description = T.let(description!(description, matches), String)
94
+ freeze
95
+ end
96
+
97
+ # Whether one value satisfies the assertion. Blanks never reach here --
98
+ # see #tally.
99
+ sig { params(value: String).returns(T::Boolean) }
100
+ def satisfied_by?(value)
101
+ allowed = @allowed
102
+ return allowed.include?(value.strip.downcase) unless allowed.nil?
103
+
104
+ !!T.must(@rule).call(value)
105
+ end
106
+
107
+ # Measures the assertion over one file's worth of values, in one pass.
108
+ # Takes anything enumerable, so a caller can hand it a lazy reader
109
+ # rather than materializing 19,321 rows.
110
+ sig { params(values: T.untyped).returns(Tally) }
111
+ def tally(values)
112
+ checked = 0
113
+ matched = 0
114
+ blank = 0
115
+ sample = T.let([], T::Array[String])
116
+ values.each do |value|
117
+ string = value.nil? ? "" : value.to_s.strip
118
+ next blank += 1 if string.empty?
119
+
120
+ checked += 1
121
+ next matched += 1 if satisfied_by?(string)
122
+
123
+ sample << string if sample.size < SAMPLE_SIZE
124
+ end
125
+ Tally.new(shape: self, checked: checked, matched: matched, blank: blank, sample: sample)
126
+ end
127
+
128
+ sig { returns(String) }
129
+ def to_s = "#{name} #{description} on at least #{percentage(at_least)} of rows"
130
+
131
+ sig { returns(String) }
132
+ def inspect = "#<#{self.class} #{self}>"
133
+
134
+ # A percentage as a report prints one: no decimal where there is nothing
135
+ # after the point, since "99%" is what was declared and "99.0%" is not.
136
+ sig { params(ratio: Float).returns(String) }
137
+ def self.percentage(ratio)
138
+ value = (ratio * 100).round(1)
139
+ value == value.to_i ? "#{value.to_i}%" : "#{value}%"
140
+ end
141
+
142
+ sig { params(ratio: Float).returns(String) }
143
+ def percentage(ratio) = ColumnShape.percentage(ratio)
144
+
145
+ private
146
+
147
+ sig { params(value: T.untyped).returns(Symbol) }
148
+ def name!(value)
149
+ string = value.to_s.strip
150
+ raise InvalidArgument, "a column shape needs a column name" if string.empty?
151
+
152
+ string.to_sym
153
+ end
154
+
155
+ sig { params(value: T.untyped).returns(T::Array[String]) }
156
+ def allowed!(value)
157
+ list = Array(value).map { |entry| entry.to_s.strip.downcase }.reject(&:empty?)
158
+ raise InvalidArgument, "allowing: needs at least one value" if list.empty?
159
+
160
+ list.uniq.freeze
161
+ end
162
+
163
+ sig do
164
+ params(matches: T.untyped, satisfying: T.untyped)
165
+ .returns(T.nilable(T.proc.params(value: String).returns(T.untyped)))
166
+ end
167
+ def rule!(matches, satisfying)
168
+ return nil unless @allowed.nil?
169
+ return ->(value) { matches.match?(value) } if matches.is_a?(Regexp)
170
+ return ->(value) { satisfying.call(value) } if satisfying.respond_to?(:call)
171
+
172
+ raise InvalidArgument,
173
+ "a column shape needs one of matches: (a Regexp), allowing: (the values it may hold) " \
174
+ "or satisfying: (a callable)"
175
+ end
176
+
177
+ sig { params(value: T.untyped).returns(Float) }
178
+ def at_least!(value)
179
+ ratio = Float(value)
180
+ return ratio if ratio.between?(0.0, 1.0)
181
+
182
+ raise InvalidArgument, "at_least must be a share between 0 and 1, got #{value.inspect}"
183
+ end
184
+
185
+ sig { params(value: T.untyped, matches: T.untyped).returns(String) }
186
+ def description!(value, matches)
187
+ string = value.to_s.strip
188
+ return -string unless string.empty?
189
+ return -"one of #{@allowed.join(", ")}" unless @allowed.nil?
190
+
191
+ matches.is_a?(Regexp) ? -"matching #{matches.inspect}" : "as declared"
192
+ end
193
+
194
+ # What a ColumnShape measured over one file. Frozen, serializable, and
195
+ # deliberately keeping the values it rejected: "ent_num is numeric on 0%
196
+ # of rows" says a column moved, and the sample says which one moved into
197
+ # it.
198
+ #
199
+ # @api private
200
+ class Tally
201
+ extend T::Sig
202
+
203
+ sig { returns(ColumnShape) }
204
+ attr_reader :shape
205
+
206
+ # Non-blank values measured.
207
+ sig { returns(Integer) }
208
+ attr_reader :checked
209
+
210
+ sig { returns(Integer) }
211
+ attr_reader :matched
212
+
213
+ # Values the publisher left empty, which are not measured -- see the
214
+ # ColumnShape comment.
215
+ sig { returns(Integer) }
216
+ attr_reader :blank
217
+
218
+ # Up to ColumnShape::SAMPLE_SIZE of the values that did not satisfy the
219
+ # assertion, as evidence.
220
+ sig { returns(T::Array[String]) }
221
+ attr_reader :sample
222
+
223
+ sig do
224
+ params(shape: ColumnShape, checked: Integer, matched: Integer, blank: Integer,
225
+ sample: T::Array[String]).void
226
+ end
227
+ def initialize(shape:, checked:, matched:, blank: 0, sample: [])
228
+ @shape = T.let(shape, ColumnShape)
229
+ @checked = T.let(checked, Integer)
230
+ @matched = T.let(matched, Integer)
231
+ @blank = T.let(blank, Integer)
232
+ @sample = T.let(sample.dup.freeze, T::Array[String])
233
+ freeze
234
+ end
235
+
236
+ sig { returns(Symbol) }
237
+ def name = shape.name
238
+
239
+ # 1.0 for a column with nothing in it to measure. A file where the
240
+ # column is blank on every row is a different complaint -- a fill rate
241
+ # that fell to zero -- and reporting it here as a shape violation would
242
+ # say the wrong thing about it twice.
243
+ sig { returns(Float) }
244
+ def ratio = checked.zero? ? 1.0 : matched.fdiv(checked)
245
+
246
+ sig { returns(T::Boolean) }
247
+ def ok? = ratio >= shape.at_least
248
+
249
+ sig { returns(T::Boolean) }
250
+ def failed? = !ok?
251
+
252
+ sig { returns(T::Hash[Symbol, T.untyped]) }
253
+ def to_h
254
+ { column: name, description: shape.description, checked: checked, matched: matched,
255
+ blank: blank, ratio: ratio.round(4), at_least: shape.at_least, sample: sample }
256
+ end
257
+
258
+ sig { returns(String) }
259
+ def to_s
260
+ "#{name} #{shape.description} on #{shape.percentage(ratio)} of #{checked} rows " \
261
+ "(expected #{shape.percentage(shape.at_least)})#{evidence}"
262
+ end
263
+
264
+ sig { returns(String) }
265
+ def inspect = "#<#{self.class} #{self}>"
266
+
267
+ private
268
+
269
+ sig { returns(String) }
270
+ def evidence = sample.empty? ? "" : ": #{sample.map(&:inspect).join(", ")}"
271
+ end
272
+ end
273
+ end
274
+ end