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,251 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/entity"
7
+ require "active_sanction/name"
8
+ require "active_sanction/address"
9
+ require "active_sanction/identifier"
10
+ require "active_sanction/sources/remarks"
11
+ require "active_sanction/sources/ofac/remarks_parser"
12
+
13
+ module ActiveSanction
14
+ module Sources
15
+ class Ofac < Base
16
+ # One joined OFAC record -- a row of the primary file plus the ALT and
17
+ # ADD rows that share its `ent_num` -- turned into an Entity.
18
+ #
19
+ # Separate from the adapter because they are two jobs: the adapter says
20
+ # what the list is and where it lives, and this says what OFAC's columns
21
+ # mean. The mapping is where all the judgment sits, so it is worth being
22
+ # able to read it on its own.
23
+ #
24
+ # Shared by both OFAC adapters, because SDN.CSV and CONS_PRIM.CSV are
25
+ # the same twelve columns with the same conventions -- the same is true
26
+ # of ALT and ADD -- and the only thing that differs between them is
27
+ # which list a row is on. `source` is passed in rather than hard-coded
28
+ # for that reason, and #remark_fields is the hook a subclass overrides
29
+ # to record anything its own list publishes on top.
30
+ class Record
31
+ extend T::Sig
32
+
33
+ # OFAC's `SDN_Type` as published, and what each maps to. Blank is the
34
+ # one that matters: 9,923 of 19,321 rows leave it empty and every one
35
+ # of them is an organization. Defaulting blank to "unknown" would
36
+ # mis-type the largest group in the list.
37
+ TYPES = T.let(
38
+ { "individual" => :individual, "vessel" => :vessel, "aircraft" => :aircraft }.freeze,
39
+ T::Hash[String, Symbol]
40
+ )
41
+
42
+ # The SDN_Type values this record class knows how to map, as a method
43
+ # rather than as the constant itself: a subclass reading a list that
44
+ # publishes a fourth one declares its own TYPES, and the caller asking
45
+ # -- Ofac#column_assertions -- has only the class in hand.
46
+ sig { returns(T::Array[String]) }
47
+ def self.published_types = TYPES.keys
48
+
49
+ # ALT.CSV's `alt_type`, which OFAC publishes as exactly these three.
50
+ ALIAS_KINDS = T.let({ "aka" => :aka, "fka" => :fka, "nka" => :nka }.freeze, T::Hash[String, Symbol])
51
+
52
+ # Multiple programs arrive in one field separated by `] [`:
53
+ # `"IRAQ2] [IRGC] [SDGT"` is three sanctions programs, not one.
54
+ PROGRAM_SEPARATOR = T.let(/\]\s*\[/, Regexp)
55
+
56
+ # Columns OFAC publishes outside its Remarks field that the canonical
57
+ # model has no home for. Appended to remarks rather than dropped: a
58
+ # vessel's flag and owner are real screening signal, and losing them to
59
+ # keep a schema tidy is the wrong trade.
60
+ COLUMNS_IN_REMARKS = T.let({
61
+ title: "Title", vessel_type: "Vessel type", tonnage: "Tonnage",
62
+ gross_registered_tonnage: "GRT", vessel_flag: "Vessel flag", vessel_owner: "Vessel owner"
63
+ }.freeze, T::Hash[Symbol, String])
64
+
65
+ # A name matches one already on the record when the letters and digits
66
+ # agree; OFAC's own punctuation does not have to. 24 of the 4,349
67
+ # inline aliases repeat an ALT.CSV row, and the rest are names the list
68
+ # publishes nowhere else.
69
+ INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
70
+
71
+ sig { returns(Parsers::DelimitedTable::Row) }
72
+ attr_reader :row
73
+
74
+ # The list this row is on, passed in rather than hard-coded: SDN.CSV
75
+ # and CONS_PRIM.CSV are read by this same class.
76
+ sig { returns(Symbol) }
77
+ attr_reader :source
78
+
79
+ sig { returns(T::Array[Parsers::DelimitedTable::Row]) }
80
+ attr_reader :aliases
81
+
82
+ sig { returns(T::Array[Parsers::DelimitedTable::Row]) }
83
+ attr_reader :addresses
84
+
85
+ sig do
86
+ params(row: Parsers::DelimitedTable::Row, source: Symbol,
87
+ aliases: T::Array[Parsers::DelimitedTable::Row],
88
+ addresses: T::Array[Parsers::DelimitedTable::Row]).void
89
+ end
90
+ def initialize(row:, source:, aliases: [], addresses: [])
91
+ @row = T.let(row, Parsers::DelimitedTable::Row)
92
+ @source = T.let(source, Symbol)
93
+ @aliases = T.let(aliases, T::Array[Parsers::DelimitedTable::Row])
94
+ @addresses = T.let(addresses, T::Array[Parsers::DelimitedTable::Row])
95
+ @parsed_remarks = T.let(nil, T.nilable(RemarksParser))
96
+ end
97
+
98
+ # The entity, or nil for a row with no name -- which cannot be screened
99
+ # against and is never what OFAC meant to publish.
100
+ sig { returns(T.nilable(Entity)) }
101
+ def entity
102
+ return nil if row.null?(:sdn_name)
103
+
104
+ # `new(**hash)` past required keyword parameters is one of the few
105
+ # things Sorbet cannot check statically. #from_remarks below is the
106
+ # hash, and it carries the two members it names and nothing else.
107
+ T.unsafe(Entity).new(source: source, source_ref: row[:ent_num], type: type,
108
+ names: names, addresses: places, identifiers: identifiers,
109
+ programs: programs, remarks: remarks, **from_remarks)
110
+ end
111
+
112
+ # The members no OFAC column feeds. De-duplicated because one entity's
113
+ # remark can report the same nationality twice -- "nationality Iran;
114
+ # alt. nationality Iran" -- and a record that claims one thing twice
115
+ # is not a record that claims it more strongly.
116
+ sig { returns(T::Hash[Symbol, T.untyped]) }
117
+ def from_remarks
118
+ { dates_of_birth: parsed_remarks.dates_of_birth.uniq,
119
+ nationalities: parsed_remarks.nationalities.uniq }
120
+ end
121
+
122
+ # OFAC's free text, read for the fields it has no columns for. Exposed
123
+ # rather than kept private because the adapter folds every record's
124
+ # into one coverage figure, which is how drift in a heuristic parser
125
+ # gets noticed at all.
126
+ sig { returns(RemarksParser) }
127
+ def parsed_remarks
128
+ @parsed_remarks ||= RemarksParser.new(row[:remarks])
129
+ end
130
+
131
+ sig { returns(Symbol) }
132
+ def type
133
+ published = row[:sdn_type]
134
+ return :organization if published.nil?
135
+
136
+ TYPES.fetch(published.downcase, :organization)
137
+ end
138
+
139
+ # True when OFAC published a type this adapter does not know. Worth
140
+ # surfacing rather than silently absorbing: a new value here means the
141
+ # list grew a category, and everything in it is currently being called
142
+ # an organization.
143
+ sig { returns(T::Boolean) }
144
+ def unknown_type?
145
+ published = row[:sdn_type]
146
+ !published.nil? && !TYPES.key?(published.downcase)
147
+ end
148
+
149
+ # The primary name first, then every alias in the order OFAC filed it.
150
+ # `alt_num` ordering is the closest thing these aliases have to a
151
+ # priority, so it is preserved rather than sorted away. Last come the
152
+ # aliases that appear only inside the remark -- 4,325 names that are in
153
+ # no other column of any of the three files.
154
+ sig { returns(T::Array[Name]) }
155
+ def names
156
+ published = [Name.new(value: row[:sdn_name], kind: :primary)] + alias_names
157
+ published + new_names(published, parsed_remarks.aliases)
158
+ end
159
+
160
+ sig { returns(T::Array[Address]) }
161
+ def places
162
+ addresses.filter_map { |address| place(address) }
163
+ end
164
+
165
+ # The call sign, then every document number the remark named. Compared
166
+ # through Identifier's own equality, which already treats `AB-123 456`
167
+ # and `ab123456` as one document, so a number OFAC wrote twice does not
168
+ # become two.
169
+ sig { returns(T::Array[Identifier]) }
170
+ def identifiers
171
+ (call_sign + parsed_remarks.identifiers).uniq
172
+ end
173
+
174
+ # A vessel's call sign is a registered, near-unique string, which makes
175
+ # it far more like a document number than like a name -- and an
176
+ # Identifier is matchable where a line of remarks is not. Filed as
177
+ # :other because it is not any of the document kinds the model names.
178
+ sig { returns(T::Array[Identifier]) }
179
+ def call_sign
180
+ return [] if row.null?(:call_sign)
181
+
182
+ [Identifier.new(kind: :other, value: row[:call_sign], note: "call sign")]
183
+ rescue ArgumentError
184
+ []
185
+ end
186
+
187
+ sig { returns(T::Array[String]) }
188
+ def programs
189
+ return [] if row.null?(:program)
190
+
191
+ T.must(row[:program]).split(PROGRAM_SEPARATOR).map { |program| program.strip.delete("[]") }.reject(&:empty?)
192
+ end
193
+
194
+ # OFAC's remark verbatim, then the columns that have nowhere else to
195
+ # go, behind the marker that makes them trivial to strip again.
196
+ sig { returns(T.nilable(String)) }
197
+ def remarks
198
+ Remarks.build(row[:remarks], remark_fields)
199
+ end
200
+
201
+ # The label/value pairs appended behind the marker. A subclass reading
202
+ # a list that publishes something more -- which sub-list of the
203
+ # consolidated file a row is on -- prepends to this rather than
204
+ # rewriting #remarks.
205
+ sig { returns(T::Array[T.untyped]) }
206
+ def remark_fields
207
+ COLUMNS_IN_REMARKS.map { |column, label| [label, row[column]] }
208
+ end
209
+
210
+ private
211
+
212
+ sig { params(published: T::Array[Name], candidates: T::Array[Name]).returns(T::Array[Name]) }
213
+ def new_names(published, candidates)
214
+ seen = published.map { |name| key(name) }
215
+ candidates.reject { |name| seen.include?(key(name)) }
216
+ end
217
+
218
+ sig { params(name: Name).returns(String) }
219
+ def key(name) = name.value.upcase.gsub(INSIGNIFICANT, "")
220
+
221
+ sig { returns(T::Array[Name]) }
222
+ def alias_names
223
+ aliases.filter_map do |alt|
224
+ next nil if alt.null?(:alt_name)
225
+
226
+ Name.new(value: alt[:alt_name], kind: ALIAS_KINDS.fetch(alt.fetch(:alt_type).to_s.downcase, :aka))
227
+ end
228
+ end
229
+
230
+ # ADD.CSV combines city, state, province and postal code into one
231
+ # column, so "London EC3N 1DY" arrives undivided. It is filed under
232
+ # `city` whole rather than split on a guess: a rule that turns
233
+ # "London EC3N 1DY" into a city and a postcode also turns "Dubai" into
234
+ # a city and turns half of Latin America into nonsense.
235
+ # 3,211 of ADD.CSV's 25,078 rows carry an `ent_num` and an `add_num`
236
+ # and then nothing at all -- no street, no city, no country, no
237
+ # remark. They are dropped rather than kept as empty addresses, which
238
+ # is why a full sync yields ~21.9k addresses from ~25.1k rows. An
239
+ # Address that locates nothing cannot be screened on and would only
240
+ # inflate the count.
241
+ sig { params(address: Parsers::DelimitedTable::Row).returns(T.nilable(Address)) }
242
+ def place(address)
243
+ Address.new(street: address[:address], city: address[:city_state_province_postal_code],
244
+ country: address[:country], note: address[:add_remarks])
245
+ rescue ArgumentError
246
+ nil
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,120 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Sources
8
+ class Ofac < Base
9
+ class RemarksParser
10
+ # How much of OFAC's free text the parser understood, accumulated over
11
+ # a whole sync.
12
+ #
13
+ # coverage = source.remarks_coverage
14
+ # coverage.percentage # => 93.4
15
+ # coverage.to_s # => "recognized 82943 of 88827 segments (93.4%), 41216 extracted"
16
+ # coverage.top(3)
17
+ # # => [["Member of the", 615], ["ICTY indictee.", 45], ["all offices worldwide.", 43]]
18
+ #
19
+ # ### Why a number and not a pass/fail
20
+ #
21
+ # RemarksParser is heuristic against text a government writes for
22
+ # people, and OFAC changes how it writes things without telling
23
+ # anybody. The failure that matters is not a crash -- nothing here
24
+ # raises, and an unread segment is still in the remark -- it is the
25
+ # quiet one, where a re-spelled label stops producing passports for six
26
+ # months and no one notices because the import still succeeds.
27
+ #
28
+ # A percentage recorded on every sync makes that visible: it moves when
29
+ # the file's vocabulary moves. #top is what turns the movement into
30
+ # work, since the shapes that suddenly appear in the unrecognized
31
+ # histogram are the new spellings, ranked by how many records they cost.
32
+ #
33
+ # `recognized` counts a segment matched as prose as well as one that
34
+ # produced a value, because "we know this citation carries no fields"
35
+ # and "we have never seen this" are different states and only the
36
+ # second is actionable. `extracted` counts the second kind alone.
37
+ class Coverage
38
+ extend T::Sig
39
+
40
+ # A shape, not a segment: enough leading words to recognize the
41
+ # pattern, with digits masked so that 4,000 distinct tax numbers
42
+ # collapse into one line rather than flooding the histogram.
43
+ SHAPE_WORDS = T.let(3, Integer)
44
+
45
+ sig { returns(Integer) }
46
+ attr_reader :segments
47
+
48
+ sig { returns(Integer) }
49
+ attr_reader :extracted
50
+
51
+ # Extracted plus prose: a citation known to carry no fields is
52
+ # recognized, and only what is neither is actionable.
53
+ sig { returns(Integer) }
54
+ attr_reader :recognized
55
+
56
+ # Unrecognized shapes to how many segments each cost.
57
+ sig { returns(T::Hash[String, Integer]) }
58
+ attr_reader :unknown
59
+
60
+ sig { void }
61
+ def initialize
62
+ @segments = T.let(0, Integer)
63
+ @extracted = T.let(0, Integer)
64
+ @recognized = T.let(0, Integer)
65
+ @unknown = T.let(Hash.new(0), T::Hash[String, Integer])
66
+ end
67
+
68
+ # Folds one parsed remark in. Returns self, so a caller can chain it
69
+ # into a fold over the file.
70
+ sig { params(parsed: RemarksParser).returns(T.self_type) }
71
+ def record(parsed)
72
+ @segments += parsed.segments.size
73
+ @extracted += parsed.extracted.size
74
+ @recognized += parsed.extracted.size + parsed.prose.size
75
+ parsed.unrecognized.each do |segment|
76
+ shape = shape(segment)
77
+ @unknown[shape] = @unknown.fetch(shape, 0) + 1
78
+ end
79
+ self
80
+ end
81
+
82
+ # 0.0 for an empty run rather than a division by zero: a sync that
83
+ # read no remarks has no coverage to report, and neither perfect nor
84
+ # nil is an honest way to say so.
85
+ sig { returns(Float) }
86
+ def ratio = segments.zero? ? 0.0 : recognized.fdiv(segments)
87
+
88
+ sig { returns(Float) }
89
+ def percentage = (ratio * 100).round(1).to_f
90
+
91
+ # The unrecognized shapes that cost the most segments, which is where
92
+ # a new label shows up first.
93
+ sig { params(count: Integer).returns(T::Array[T.untyped]) }
94
+ def top(count = 10) = unknown.sort_by { |shape, tally| [-tally, shape] }.first(count)
95
+
96
+ sig { returns(T::Hash[Symbol, T.untyped]) }
97
+ def to_h
98
+ { segments: segments, extracted: extracted, recognized: recognized,
99
+ ratio: ratio, unknown: unknown.size }
100
+ end
101
+
102
+ sig { returns(String) }
103
+ def to_s
104
+ "recognized #{recognized} of #{segments} segments (#{percentage}%), #{extracted} extracted"
105
+ end
106
+
107
+ sig { returns(String) }
108
+ def inspect = "#<#{self.class} #{self}>"
109
+
110
+ private
111
+
112
+ sig { params(segment: String).returns(String) }
113
+ def shape(segment)
114
+ -segment.split(/\s+/).first(SHAPE_WORDS).join(" ").gsub(/\d/, "#")
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,121 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Sources
8
+ class Ofac < Base
9
+ class RemarksParser
10
+ # What OFAC calls things: every label the parser knows how to find at
11
+ # the front of a remark segment, and the patterns compiled from them.
12
+ #
13
+ # Kept apart from the parsing because they change for different
14
+ # reasons. How a segment is read is settled; which words OFAC opens one
15
+ # with is not, and is what a maintainer edits when Coverage#top shows a
16
+ # spelling nobody has seen before. The tail of that list is long and
17
+ # thin -- a dozen country-specific labels appear fewer than 30 times
18
+ # each -- so this table is deliberately the high-volume ones rather
19
+ # than an attempt at all of them.
20
+ module Vocabulary
21
+ extend T::Sig
22
+
23
+ # The labels that map onto a canonical field. `citizen` and
24
+ # `nationality` are one field written two ways, and OFAC uses both.
25
+ FIELDS = T.let({
26
+ "DOB" => :date_of_birth,
27
+ "POB" => :place_of_birth,
28
+ "nationality" => :nationality,
29
+ "citizen" => :nationality,
30
+ "Gender" => :gender,
31
+ "a.k.a." => :aka,
32
+ "f.k.a." => :fka,
33
+ "n.k.a." => :nka
34
+ }.freeze, T::Hash[String, Symbol])
35
+
36
+ # Every document label OFAC writes, grouped by the Identifier kind it
37
+ # means. One kind covers many labels because governments name the
38
+ # same document differently -- an R.F.C. is Mexico's tax number and a
39
+ # NIT is Colombia's -- and a matcher comparing numbers should not
40
+ # have to know whose vocabulary it is holding. The published label is
41
+ # not lost: it becomes the identifier's `note`, because :tax_id is
42
+ # our word for it and "R.F.C." is theirs, and a user justifying a hit
43
+ # needs to see theirs.
44
+ DOCUMENTS = T.let({
45
+ passport: ["Passport", "Diplomatic Passport"],
46
+ national_id: [
47
+ "National ID No.", "Identification Number", "Cedula No.", "C.U.R.P.", "C.U.I.P.",
48
+ "D.N.I.", "C.I.N.", "Driver's License No.", "Birth Certificate Number", "Residency Number"
49
+ ],
50
+ tax_id: [
51
+ "Tax ID No.", "R.F.C.", "V.A.T. Number", "NIT", "RUC", "RIF",
52
+ "Unified Social Credit Code (USCC)"
53
+ ],
54
+ registration_number: [
55
+ "Registration Number", "Business Registration Number", "Business Registration Document",
56
+ "Company Number", "Commercial Registry Number", "Registration ID", "Government Gazette Number",
57
+ "Folio Mercantil No.", "Matricula Mercantil No", "Legal Entity Number", "Chamber of Commerce Number",
58
+ "Trade License No.", "License", "C.R. No.", "Economic Register Number (CBLS)",
59
+ "Central Registration System Number", "D-U-N-S Number", "Enterprise Number", "Business Number",
60
+ "Entity Code", "Public Registration Number"
61
+ ],
62
+ # Registered strings that are not documents: a vessel's IMO number,
63
+ # a bank's SWIFT code, an aircraft's serial. :other is a real
64
+ # answer -- a number we cannot classify still matches on its number.
65
+ other: [
66
+ "MMSI", "Vessel Registration Identification IMO", "Vessel Registration Identification",
67
+ "Identification Number IMO", "Company Number IMO", "SWIFT/BIC",
68
+ "Aircraft Manufacturer's Serial Number (MSN)", "Aircraft Tail Number",
69
+ # Published with its own gloss attached, every time, all 52 of them.
70
+ "Aircraft Construction Number (also called L/N or S/N or F/N)", "Aircraft Construction Number"
71
+ ]
72
+ }.freeze, T::Hash[Symbol, T::Array[String]])
73
+
74
+ # Shapes that are recognized and carry nothing to extract: statutory
75
+ # citations, relationship notes, contact details, the date a company
76
+ # rather than a person was established. Naming them is what makes the
77
+ # coverage statistic mean something -- without this list the number
78
+ # would sit near half forever and real drift would hide in the noise.
79
+ PROSE = T.let([
80
+ "Secondary sanctions risk", "Additional Sanctions Information", "Linked To",
81
+ "Transactions Prohibited For Persons Owned or Controlled By U.S. Financial Institutions",
82
+ "Organization Established Date", "Organization Type", "Target Type", "Executive Order",
83
+ "CAATSA Section", "For more information", "Website", "Email Address", "Phone Number",
84
+ "Telephone", "Fax", "Vessel Year of Build", "Former Vessel Flag", "Aircraft Manufacture Date",
85
+ "Aircraft Model", "Aircraft Operator"
86
+ ].freeze, T::Array[String])
87
+
88
+ # A label is followed by whitespace, a `#` (`NIT # 123`) or a colon,
89
+ # and may carry one full stop this table does not spell ("Matricula
90
+ # Mercantil No." against "Matricula Mercantil No"). The lookahead is
91
+ # what stops "Passport" from matching the first word of a longer
92
+ # label, and the longest-first ordering is what stops "Business
93
+ # Registration Number" from being read as a stray word and then
94
+ # "Registration Number".
95
+ #
96
+ # The alternation is escaped and joined by hand rather than built
97
+ # with Regexp.union, which embeds its own `(?-mix:...)` and would
98
+ # switch case-insensitivity back off for the labels inside it.
99
+ sig { params(labels: T::Array[String], tail: String).returns(Regexp) }
100
+ def self.pattern(labels, tail)
101
+ alternation = labels.sort_by { |label| -label.length }.map { |label| Regexp.escape(label) }.join("|")
102
+ /\A(?<label>#{alternation})\.?(?=[\s#:]|\z)[\s#:]*#{tail}\z/i
103
+ end
104
+
105
+ DOCUMENT_KINDS = T.let(
106
+ DOCUMENTS.each_with_object({}) do |(kind, labels), lookup|
107
+ labels.each { |label| lookup[label.downcase] = kind }
108
+ end.freeze,
109
+ T::Hash[String, Symbol]
110
+ )
111
+
112
+ FIELD_KINDS = T.let(FIELDS.transform_keys(&:downcase).freeze, T::Hash[String, Symbol])
113
+
114
+ FIELD_PATTERN = T.let(pattern(FIELDS.keys, "(?<value>.*)"), Regexp)
115
+ DOCUMENT_PATTERN = T.let(pattern(DOCUMENTS.values.flatten, "(?<rest>.*)"), Regexp)
116
+ PROSE_PATTERN = T.let(pattern(PROSE, ".*"), Regexp)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end