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,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