active_sanction 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Corporate legal forms, stripped from organization names only.
|
|
2
|
+
#
|
|
3
|
+
# "Rosneft Oil Company" and "Rosneft" are the same company, and a scorer that
|
|
4
|
+
# sees one token in common out of three will not say so. The legal form is the
|
|
5
|
+
# part of an organization's name that carries no identifying information: it
|
|
6
|
+
# says how the entity is incorporated, which every entity of its kind in that
|
|
7
|
+
# country shares.
|
|
8
|
+
#
|
|
9
|
+
# Entries are written the way a publisher writes them and folded by the same
|
|
10
|
+
# Normalizer::Form the names are folded by, so one entry covers every casing
|
|
11
|
+
# and every accent: `LTD` matches `Ltd`, `ltd.` and `LTD.` with no other line
|
|
12
|
+
# in this file. An entry that folds to several tokens is matched as a
|
|
13
|
+
# contiguous phrase, which is what "PUBLIC JOINT STOCK COMPANY GAZPROM" needs
|
|
14
|
+
# to become "gazprom" rather than "public joint stock gazprom".
|
|
15
|
+
#
|
|
16
|
+
# Folding does not join tokens, though, so an abbreviation written with stops
|
|
17
|
+
# is a different entry from the same one without them: `L.L.C.` folds to three
|
|
18
|
+
# tokens and `LLC` to one, and both are below.
|
|
19
|
+
#
|
|
20
|
+
# Anything here that collides with particles.txt is discarded, not applied.
|
|
21
|
+
|
|
22
|
+
# English
|
|
23
|
+
LLC
|
|
24
|
+
L.L.C.
|
|
25
|
+
LTD
|
|
26
|
+
LIMITED
|
|
27
|
+
INC
|
|
28
|
+
INCORPORATED
|
|
29
|
+
CORP
|
|
30
|
+
CORPORATION
|
|
31
|
+
CO
|
|
32
|
+
COMPANY
|
|
33
|
+
PLC
|
|
34
|
+
|
|
35
|
+
# German and Nordic
|
|
36
|
+
GMBH
|
|
37
|
+
AG
|
|
38
|
+
AB
|
|
39
|
+
AS
|
|
40
|
+
OY
|
|
41
|
+
KG
|
|
42
|
+
|
|
43
|
+
# Romance
|
|
44
|
+
SA
|
|
45
|
+
S.A.
|
|
46
|
+
SARL
|
|
47
|
+
SPA
|
|
48
|
+
SRL
|
|
49
|
+
|
|
50
|
+
# Low Countries
|
|
51
|
+
BV
|
|
52
|
+
NV
|
|
53
|
+
|
|
54
|
+
# South and East Asia
|
|
55
|
+
PTE
|
|
56
|
+
PVT
|
|
57
|
+
KK
|
|
58
|
+
|
|
59
|
+
# Russian and post-Soviet, both the initialism and the phrase it stands for.
|
|
60
|
+
# OFAC writes "PUBLIC JOINT STOCK COMPANY GAZPROM" as a primary name and
|
|
61
|
+
# "PJSC GAZPROM" as an alias of the same entity.
|
|
62
|
+
OOO
|
|
63
|
+
ZAO
|
|
64
|
+
OAO
|
|
65
|
+
PAO
|
|
66
|
+
JSC
|
|
67
|
+
PJSC
|
|
68
|
+
OJSC
|
|
69
|
+
JOINT STOCK COMPANY
|
|
70
|
+
OPEN JOINT STOCK COMPANY
|
|
71
|
+
CLOSED JOINT STOCK COMPANY
|
|
72
|
+
PUBLIC JOINT STOCK COMPANY
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Function words carried by organization names, stripped from those only.
|
|
2
|
+
#
|
|
3
|
+
# "Central Bank of the Russian Federation" and "Bank of Russia" are the same
|
|
4
|
+
# institution, and the tokens they do not share should not be `of` and `the`.
|
|
5
|
+
#
|
|
6
|
+
# Individuals are left alone: a person's name has no function words, and the
|
|
7
|
+
# tokens that look like them are the particles this list must not touch.
|
|
8
|
+
#
|
|
9
|
+
# `AL` is here because an organization name transliterated from Arabic often
|
|
10
|
+
# carries the article, and it is also in particles.txt -- which wins. The
|
|
11
|
+
# collision is real and the resolution is deliberate: `AL` stays in the
|
|
12
|
+
# normalized name. Removing an article costs one token of noise, and stripping
|
|
13
|
+
# `al` from "Al Rajhi Bank" or from any of the several hundred SDN entries
|
|
14
|
+
# whose name begins with it costs the name's structure.
|
|
15
|
+
|
|
16
|
+
THE
|
|
17
|
+
AND
|
|
18
|
+
OF
|
|
19
|
+
FOR
|
|
20
|
+
AL
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Particles that must survive normalization, whatever else a list says.
|
|
2
|
+
#
|
|
3
|
+
# These are the pieces of a name that look like noise to a stopword filter and
|
|
4
|
+
# are not: the Arabic relational particles, the Slavic and European nobiliary
|
|
5
|
+
# ones. Stripping `bin` from "Osama bin Laden" or `abd` from "Shaykh Umar Abd
|
|
6
|
+
# Al Rahman" -- a real SDN entry -- does not make the name shorter, it makes it
|
|
7
|
+
# a different name, and one that both misses the person it should hit and hits
|
|
8
|
+
# people it should not.
|
|
9
|
+
#
|
|
10
|
+
# This list takes precedence over every strip list, including a host's own: an
|
|
11
|
+
# entry containing any token named here is dropped from the strip lists rather
|
|
12
|
+
# than applied. That is the entire reason the file exists, and it is why `AL`
|
|
13
|
+
# can sit in organization_stopwords.txt and still reach the scorers.
|
|
14
|
+
#
|
|
15
|
+
# An entry only has an effect where it collides with something being stripped.
|
|
16
|
+
# Adding a particle no strip list names changes nothing, and is harmless.
|
|
17
|
+
|
|
18
|
+
# Arabic
|
|
19
|
+
bin
|
|
20
|
+
ibn
|
|
21
|
+
bint
|
|
22
|
+
abu
|
|
23
|
+
abd
|
|
24
|
+
al
|
|
25
|
+
el
|
|
26
|
+
|
|
27
|
+
# Dutch, German, Iberian and Italian
|
|
28
|
+
van
|
|
29
|
+
von
|
|
30
|
+
de
|
|
31
|
+
da
|
|
32
|
+
del
|
|
33
|
+
della
|
|
34
|
+
di
|
|
35
|
+
dos
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
class Normalizer
|
|
8
|
+
class Dictionary
|
|
9
|
+
# One entity type's strip lists, resolved down to the only thing the fold
|
|
10
|
+
# needs: which token sequences to drop, already folded and with the
|
|
11
|
+
# preserved particles taken back out.
|
|
12
|
+
#
|
|
13
|
+
# stoplist = ActiveSanction::Normalizer::Dictionary.default.stoplist(:organization)
|
|
14
|
+
# stoplist.reject(%w[public joint stock company gazprom]) # => ["gazprom"]
|
|
15
|
+
#
|
|
16
|
+
# Resolution happens once, when the Dictionary is built, because the
|
|
17
|
+
# alternative is doing it per name: a strip list is a few dozen entries
|
|
18
|
+
# and an index build folds 46,000 names.
|
|
19
|
+
#
|
|
20
|
+
# ### Sequences rather than tokens
|
|
21
|
+
#
|
|
22
|
+
# A dictionary entry is folded by the same Form the names are folded by,
|
|
23
|
+
# and that fold turns punctuation into spaces: `L.L.C.` in the file is
|
|
24
|
+
# three tokens, and so is `L.L.C.` in a name. So an entry is matched as a
|
|
25
|
+
# contiguous sequence rather than as a token, longest first at each
|
|
26
|
+
# position. That the mechanism was forced by punctuation is incidental;
|
|
27
|
+
# what it buys is the phrases that actually matter, since "PUBLIC JOINT
|
|
28
|
+
# STOCK COMPANY GAZPROM" has to reach `gazprom` rather than `public joint
|
|
29
|
+
# stock gazprom`.
|
|
30
|
+
#
|
|
31
|
+
# Folding never joins tokens, so `LLC` is not reachable from an `L.L.C.`
|
|
32
|
+
# entry. The file carries both spellings; this class does not guess.
|
|
33
|
+
#
|
|
34
|
+
# A match is dropped wherever it appears, not only at the end. OFAC
|
|
35
|
+
# writes both "GAZPROM PAO" and "PJSC GAZPROM", and a rule that only
|
|
36
|
+
# looked at the tail would fold one of them and not the other.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
class Stoplist
|
|
40
|
+
extend T::Sig
|
|
41
|
+
|
|
42
|
+
sig { returns(Symbol) }
|
|
43
|
+
attr_reader :type
|
|
44
|
+
|
|
45
|
+
# Every entry as the token sequence it folds to, deduplicated and
|
|
46
|
+
# sorted so that two dictionaries carrying the same lists in different
|
|
47
|
+
# orders resolve to the same stoplist -- and, through #key, share a
|
|
48
|
+
# normalizer's cache.
|
|
49
|
+
sig { returns(T::Array[T::Array[String]]) }
|
|
50
|
+
attr_reader :entries
|
|
51
|
+
|
|
52
|
+
# Identifies these entries within one process, for the fold cache: the
|
|
53
|
+
# folded form of a name depends on the entity type it was folded for
|
|
54
|
+
# and on the dictionary in force, so neither can be left out of a cache
|
|
55
|
+
# key. Content-derived rather than object-derived, so a host that
|
|
56
|
+
# rebuilds an identical dictionary does not invalidate a warm cache.
|
|
57
|
+
sig { returns(String) }
|
|
58
|
+
attr_reader :key
|
|
59
|
+
|
|
60
|
+
sig { params(type: Symbol, entries: T::Array[T::Array[String]]).void }
|
|
61
|
+
def initialize(type:, entries:)
|
|
62
|
+
@type = T.let(type, Symbol)
|
|
63
|
+
@entries = T.let(entries.reject(&:empty?).uniq.sort.freeze, T::Array[T::Array[String]])
|
|
64
|
+
@index = T.let(build_index, T::Hash[String, T::Array[T::Array[String]]])
|
|
65
|
+
@key = T.let(-"#{type}:#{@entries.hash.to_s(36)}", String)
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { returns(T::Boolean) }
|
|
70
|
+
def empty? = entries.empty?
|
|
71
|
+
|
|
72
|
+
# `tokens` with every matching sequence removed.
|
|
73
|
+
#
|
|
74
|
+
# The caller decides what an empty result means; Form keeps the
|
|
75
|
+
# unstripped tokens rather than indexing a name that folded away to
|
|
76
|
+
# nothing. See Form#fold.
|
|
77
|
+
sig { params(tokens: T::Array[String]).returns(T::Array[String]) }
|
|
78
|
+
def reject(tokens)
|
|
79
|
+
kept = T.let([], T::Array[String])
|
|
80
|
+
index = 0
|
|
81
|
+
while index < tokens.size
|
|
82
|
+
length = match(tokens, index)
|
|
83
|
+
if length.zero?
|
|
84
|
+
kept << T.must(tokens[index])
|
|
85
|
+
index += 1
|
|
86
|
+
else
|
|
87
|
+
index += length
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
kept
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { returns(String) }
|
|
94
|
+
def inspect = "#<#{self.class} #{type} #{entries.size} entries>"
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Candidates by their first token, longest first, so that "public joint
|
|
99
|
+
# stock company" is tried before "company" would be reached and before
|
|
100
|
+
# a shorter entry sharing its first token could win.
|
|
101
|
+
sig { returns(T::Hash[String, T::Array[T::Array[String]]]) }
|
|
102
|
+
def build_index
|
|
103
|
+
entries.group_by { |entry| T.must(entry.first) }
|
|
104
|
+
.transform_values { |group| group.sort_by { |entry| -entry.size }.freeze }
|
|
105
|
+
.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# How many tokens the longest entry starting at `index` covers, or zero
|
|
109
|
+
# when none does.
|
|
110
|
+
sig { params(tokens: T::Array[String], index: Integer).returns(Integer) }
|
|
111
|
+
def match(tokens, index)
|
|
112
|
+
candidates = @index[T.must(tokens[index])]
|
|
113
|
+
return 0 if candidates.nil?
|
|
114
|
+
|
|
115
|
+
found = candidates.find { |entry| tokens[index, entry.size] == entry }
|
|
116
|
+
found ? found.size : 0
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/entity"
|
|
7
|
+
require "active_sanction/normalizer/form"
|
|
8
|
+
require "active_sanction/normalizer/dictionary/stoplist"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
class Normalizer
|
|
12
|
+
# The token lists the fold applies per entity type, and the one list that
|
|
13
|
+
# overrides them.
|
|
14
|
+
#
|
|
15
|
+
# dictionary = ActiveSanction::Normalizer::Dictionary.default
|
|
16
|
+
# dictionary.stoplist(:organization).reject(%w[rosneft oil company]) # => ["rosneft", "oil"]
|
|
17
|
+
# dictionary.stoplist(:individual).reject(%w[hajji abdallah]) # => ["abdallah"]
|
|
18
|
+
#
|
|
19
|
+
# Stage 1b of the matching pipeline. Form settles what a name looks like;
|
|
20
|
+
# this settles which of its tokens carry no identifying information --
|
|
21
|
+
# `LTD` on a company, `SHAYKH` on a person -- so that "Rosneft Oil Company"
|
|
22
|
+
# and "Rosneft" can score as the near-identical pair they are.
|
|
23
|
+
#
|
|
24
|
+
# ### Contextual, because the same token means different things
|
|
25
|
+
#
|
|
26
|
+
# Legal forms are stripped from organizations and honorifics from
|
|
27
|
+
# individuals, and neither is stripped from the other or from a vessel or
|
|
28
|
+
# an aircraft. This is not tidiness. `CO` is a legal form in "Bank of
|
|
29
|
+
# Kunlun Co Ltd" and the first syllable of a great many personal names;
|
|
30
|
+
# `AS` is a Norwegian company and an English word. Applying a list to the
|
|
31
|
+
# type it was written for is what keeps it from being a source of false
|
|
32
|
+
# matches everywhere else. A caller that does not know the type says so by
|
|
33
|
+
# passing none, and gets the fold and nothing else.
|
|
34
|
+
#
|
|
35
|
+
# ### The preserve list wins
|
|
36
|
+
#
|
|
37
|
+
# Whatever the strip lists say, no entry containing a particle from
|
|
38
|
+
# particles.txt is applied. `bin`, `abu`, `al` and `abd` look like noise to
|
|
39
|
+
# a stopword filter and are structural parts of the names they appear in;
|
|
40
|
+
# dropping them turns "Osama bin Laden" into a different name rather than a
|
|
41
|
+
# shorter one. The collision is real and shipped: `AL` is in
|
|
42
|
+
# organization_stopwords.txt and never strips anything, which is what the
|
|
43
|
+
# rule is for and what the suite holds it to.
|
|
44
|
+
#
|
|
45
|
+
# ### Data files, not constants
|
|
46
|
+
#
|
|
47
|
+
# The lists live in `lib/active_sanction/normalizer/dictionaries/*.txt`,
|
|
48
|
+
# one entry per line with `#` comments, because what belongs on them is
|
|
49
|
+
# settled by reading government lists rather than by reading this code --
|
|
50
|
+
# and a contributor adding `OYJ` should be sending a one-line diff, not
|
|
51
|
+
# editing a Ruby array.
|
|
52
|
+
#
|
|
53
|
+
# Entries are written as a publisher writes them (`L.L.C.`, not `l l c`)
|
|
54
|
+
# and folded by the same Form the names are folded by, so a file never
|
|
55
|
+
# spells the casing, the accents or the marks: one `LTD` covers `Ltd`,
|
|
56
|
+
# `ltd.` and `LTD`. An entry that folds to several tokens is matched as a
|
|
57
|
+
# contiguous phrase, which is what lets `L.L.C.` reach a name a publisher
|
|
58
|
+
# wrote as `L L C` -- see Stoplist.
|
|
59
|
+
#
|
|
60
|
+
# What folding does not do is join tokens, so `LLC` and `L.L.C.` are one
|
|
61
|
+
# token and three and the file carries both. That is the one thing a
|
|
62
|
+
# contributor has to know when adding an abbreviation.
|
|
63
|
+
#
|
|
64
|
+
# ### Extending it
|
|
65
|
+
#
|
|
66
|
+
# A host adds to the shipped lists with a Hash, or replaces them wholesale
|
|
67
|
+
# by building a Dictionary of its own:
|
|
68
|
+
#
|
|
69
|
+
# ActiveSanction.configure do |c|
|
|
70
|
+
# c.normalizer_dictionary = { legal_forms: %w[OYJ TBK], particles: %w[ben] }
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# Instances are frozen on construction and compare by value.
|
|
74
|
+
class Dictionary
|
|
75
|
+
extend T::Sig
|
|
76
|
+
|
|
77
|
+
# Where the shipped lists are, and the file name of each: `legal_forms`
|
|
78
|
+
# is `dictionaries/legal_forms.txt`.
|
|
79
|
+
#
|
|
80
|
+
# @api private
|
|
81
|
+
DIRECTORY = T.let(File.expand_path("dictionaries", __dir__), String)
|
|
82
|
+
|
|
83
|
+
# @api private
|
|
84
|
+
LISTS = T.let(%i[legal_forms honorifics organization_stopwords particles].freeze, T::Array[Symbol])
|
|
85
|
+
|
|
86
|
+
# Which lists are stripped from which entity type. A type absent here --
|
|
87
|
+
# `vessel`, `aircraft` -- is folded and left alone: a ship's name is not
|
|
88
|
+
# a company's, and the tokens that would be dropped from one carry
|
|
89
|
+
# meaning in the other.
|
|
90
|
+
#
|
|
91
|
+
# @api private
|
|
92
|
+
STRIPPED = T.let({
|
|
93
|
+
individual: %i[honorifics].freeze,
|
|
94
|
+
organization: %i[legal_forms organization_stopwords].freeze
|
|
95
|
+
}.freeze, T::Hash[Symbol, T::Array[Symbol]])
|
|
96
|
+
|
|
97
|
+
sig { returns(T::Array[String]) }
|
|
98
|
+
attr_reader :legal_forms
|
|
99
|
+
|
|
100
|
+
sig { returns(T::Array[String]) }
|
|
101
|
+
attr_reader :honorifics
|
|
102
|
+
|
|
103
|
+
sig { returns(T::Array[String]) }
|
|
104
|
+
attr_reader :organization_stopwords
|
|
105
|
+
|
|
106
|
+
# The entries no strip list may touch. See the class comment.
|
|
107
|
+
sig { returns(T::Array[String]) }
|
|
108
|
+
attr_reader :particles
|
|
109
|
+
|
|
110
|
+
# Every keyword is required, because a Dictionary built by hand is a
|
|
111
|
+
# replacement for the shipped one and a replacement that forgot to carry
|
|
112
|
+
# the particles over would strip `al` out of several hundred SDN names
|
|
113
|
+
# without saying anything. `Dictionary.default.merge(...)` is the way to
|
|
114
|
+
# add to the lists rather than replace them.
|
|
115
|
+
sig do
|
|
116
|
+
params(legal_forms: T.untyped, honorifics: T.untyped, organization_stopwords: T.untyped,
|
|
117
|
+
particles: T.untyped).void
|
|
118
|
+
end
|
|
119
|
+
def initialize(legal_forms:, honorifics:, organization_stopwords:, particles:)
|
|
120
|
+
@legal_forms = T.let(entries(legal_forms), T::Array[String])
|
|
121
|
+
@honorifics = T.let(entries(honorifics), T::Array[String])
|
|
122
|
+
@organization_stopwords = T.let(entries(organization_stopwords), T::Array[String])
|
|
123
|
+
@particles = T.let(entries(particles), T::Array[String])
|
|
124
|
+
@stoplists = T.let(build_stoplists, T::Hash[Symbol, Stoplist])
|
|
125
|
+
freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# What is stripped from a name of this type, or nil when nothing is --
|
|
129
|
+
# an unknown type, a type no list applies to, or a host that emptied the
|
|
130
|
+
# lists that did. Nil is the fold's fast path, not a degraded one.
|
|
131
|
+
sig { params(type: T.nilable(Symbol)).returns(T.nilable(Stoplist)) }
|
|
132
|
+
def stoplist(type)
|
|
133
|
+
return nil if type.nil?
|
|
134
|
+
|
|
135
|
+
unless Entity::TYPES.include?(type)
|
|
136
|
+
raise InvalidArgument,
|
|
137
|
+
"unknown entity type #{type.inspect}; expected one of #{Entity::TYPES.join(", ")} or nil"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
stoplist = @stoplists[type]
|
|
141
|
+
stoplist unless stoplist.nil? || stoplist.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# This dictionary's lists with more entries added. Duplicates are
|
|
145
|
+
# dropped, so merging a list a file already carries is a no-op rather
|
|
146
|
+
# than an error.
|
|
147
|
+
sig do
|
|
148
|
+
params(legal_forms: T.untyped, honorifics: T.untyped, organization_stopwords: T.untyped,
|
|
149
|
+
particles: T.untyped).returns(Dictionary)
|
|
150
|
+
end
|
|
151
|
+
def merge(legal_forms: nil, honorifics: nil, organization_stopwords: nil, particles: nil)
|
|
152
|
+
self.class.new(
|
|
153
|
+
legal_forms: @legal_forms + entries(legal_forms),
|
|
154
|
+
honorifics: @honorifics + entries(honorifics),
|
|
155
|
+
organization_stopwords: @organization_stopwords + entries(organization_stopwords),
|
|
156
|
+
particles: @particles + entries(particles)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
sig { returns(T::Hash[Symbol, T::Array[String]]) }
|
|
161
|
+
def to_h = LISTS.to_h { |list| [list, T.unsafe(public_send(list))] }
|
|
162
|
+
|
|
163
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
164
|
+
def ==(other)
|
|
165
|
+
return false unless other.instance_of?(self.class)
|
|
166
|
+
|
|
167
|
+
to_h == other.to_h
|
|
168
|
+
end
|
|
169
|
+
alias eql? ==
|
|
170
|
+
|
|
171
|
+
sig { returns(Integer) }
|
|
172
|
+
def hash = [self.class, to_h].hash
|
|
173
|
+
|
|
174
|
+
sig { returns(String) }
|
|
175
|
+
def inspect = "#<#{self.class} #{to_h.map { |list, values| "#{list}=#{values.size}" }.join(" ")}>"
|
|
176
|
+
|
|
177
|
+
class << self
|
|
178
|
+
extend T::Sig
|
|
179
|
+
|
|
180
|
+
# The shipped lists. Built at load rather than memoized on first use,
|
|
181
|
+
# so nothing has to synchronize reading four files; the constant behind
|
|
182
|
+
# it is private because this is the way to reach it.
|
|
183
|
+
sig { returns(Dictionary) }
|
|
184
|
+
def default = DEFAULT
|
|
185
|
+
|
|
186
|
+
# Reads `<directory>/<list>.txt` for each of LISTS. Public because it
|
|
187
|
+
# is how a host ships its own set of files rather than a Ruby literal,
|
|
188
|
+
# and how the suite builds a dictionary it can vary.
|
|
189
|
+
sig { params(directory: String).returns(Dictionary) }
|
|
190
|
+
def from_files(directory = DIRECTORY)
|
|
191
|
+
# `new(**hash)` past required keyword parameters is one of the few
|
|
192
|
+
# things Sorbet cannot check statically. The keys are LISTS itself.
|
|
193
|
+
T.unsafe(self).new(**LISTS.to_h { |list| [list, read(File.join(directory, "#{list}.txt"))] })
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# One entry per line; blank lines and `#` comments ignored. Comments
|
|
197
|
+
# are whole-line only -- no entry contains a `#`, and a rule that
|
|
198
|
+
# stripped from the middle would be a rule to remember when one does.
|
|
199
|
+
sig { params(path: String).returns(T::Array[String]) }
|
|
200
|
+
def read(path)
|
|
201
|
+
File.readlines(path, chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
# Entries as written, deduplicated, with the blanks a hand-edited file
|
|
208
|
+
# collects dropped. A single string is accepted as a list of one, since
|
|
209
|
+
# `c.normalizer_dictionary = { legal_forms: "OYJ" }` is what a host will
|
|
210
|
+
# write for one entry.
|
|
211
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
212
|
+
def entries(value)
|
|
213
|
+
Array(value).map { |entry| -entry.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Resolves each type's lists once: fold every entry, drop the ones the
|
|
217
|
+
# preserve list protects, hand the rest to a Stoplist. Once, rather than
|
|
218
|
+
# per name, because an index build folds 46,000 of them.
|
|
219
|
+
sig { returns(T::Hash[Symbol, Stoplist]) }
|
|
220
|
+
def build_stoplists
|
|
221
|
+
preserved = @particles.flat_map { |entry| fold(entry) }
|
|
222
|
+
STRIPPED.to_h do |type, lists|
|
|
223
|
+
sequences = lists.flat_map { |list| T.unsafe(public_send(list)) }
|
|
224
|
+
.map { |entry| fold(entry) }
|
|
225
|
+
.reject { |sequence| sequence.any? { |token| preserved.include?(token) } }
|
|
226
|
+
[type, Stoplist.new(type: type, entries: sequences)]
|
|
227
|
+
end.freeze
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The same fold the names get. A dictionary folded any other way is a
|
|
231
|
+
# dictionary that matches tokens the fold never produces.
|
|
232
|
+
sig { params(entry: String).returns(T::Array[String]) }
|
|
233
|
+
def fold(entry) = Form.new(entry).tokens
|
|
234
|
+
|
|
235
|
+
# Last, because building it runs #initialize, which calls every private
|
|
236
|
+
# method below.
|
|
237
|
+
#
|
|
238
|
+
# @api private
|
|
239
|
+
DEFAULT = T.let(from_files, Dictionary)
|
|
240
|
+
private_constant :DEFAULT
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|