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,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class Index
8
+ # One indexed name: the entity it belongs to, the name as its publisher
9
+ # wrote it, and the folded form the index and the scorers both work in.
10
+ #
11
+ # entry.entity.id # => "ofac_sdn:2674"
12
+ # entry.name.value # => "ABBAS, Abu"
13
+ # entry.form.value # => "abbas abu"
14
+ #
15
+ # An entity contributes one entry per name it carries, not one per entity.
16
+ # That is the unit a screening call actually works in: OFAC ships more
17
+ # aliases than primary names, a hit is produced by one specific spelling,
18
+ # and a MatchResult (#33) has to be able to say which. Grouping several
19
+ # entries back onto their entity is the scorer's job and is why `entity`
20
+ # is here rather than an id.
21
+ #
22
+ # Frozen, like everything the index holds -- see Index on why that is the
23
+ # whole point rather than a detail.
24
+ class Entry
25
+ extend T::Sig
26
+
27
+ # This entry's position in the index's own array, which is what the
28
+ # posting lists hold. Small integers rather than objects: a corpus of
29
+ # 46,000 names produces upwards of a million postings across the three
30
+ # feature spaces, and an array of Integers is the difference between an
31
+ # index that fits in a web process and one that does not.
32
+ sig { returns(Integer).checked(:tests) }
33
+ attr_reader :id
34
+
35
+ sig { returns(Entity).checked(:tests) }
36
+ attr_reader :entity
37
+
38
+ sig { returns(Name).checked(:tests) }
39
+ attr_reader :name
40
+
41
+ # The name folded under its entity's type, which is what makes the
42
+ # stoplists apply -- `LTD` is dropped from an organization and `SHAYKH`
43
+ # from an individual. Folded once, here, and handed to the scorers as it
44
+ # stands: see Normalizer for why a second fold anywhere is a bug.
45
+ sig { returns(Normalizer::Form).checked(:tests) }
46
+ attr_reader :form
47
+
48
+ sig { params(id: Integer, entity: Entity, name: Name, form: Normalizer::Form).void.checked(:tests) }
49
+ def initialize(id:, entity:, name:, form:)
50
+ @id = id
51
+ @entity = entity
52
+ @name = name
53
+ @form = form
54
+ freeze
55
+ end
56
+
57
+ # The source the entity came from, which a query may be filtered by and
58
+ # which every hit has to name.
59
+ sig { returns(Symbol).checked(:tests) }
60
+ def source = entity.source
61
+
62
+ sig { returns(String) }
63
+ def inspect = "#<#{self.class} #{id} #{name.value.inspect} (#{entity.id})>"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,112 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class Index
8
+ # The three things a folded name is looked up by.
9
+ #
10
+ # form = ActiveSanction::Normalizer.call("ABBAS, Abu")
11
+ #
12
+ # ActiveSanction::Index::Features.tokens(form) # => ["abbas", "abu"]
13
+ # ActiveSanction::Index::Features.trigrams(form) # => [" ab", "abb", "bba", ...]
14
+ # ActiveSanction::Index::Features.phonetics(form) # => ["APS", "AP"]
15
+ #
16
+ # One module rather than two code paths, because the whole of an index's
17
+ # correctness is that a name is described the same way when it is stored
18
+ # and when it is asked for. A build that padded its trigrams and a query
19
+ # that did not would retrieve nothing at all, and would look exactly like
20
+ # a corpus with no matches in it.
21
+ #
22
+ # ### Why three
23
+ #
24
+ # Each one fails where the next one works, which is the same argument the
25
+ # scorers make and for the same reason -- except that here a miss is
26
+ # final. A name the index does not retrieve is never compared to anything,
27
+ # so this stage is built for recall and the precision is #32's job.
28
+ #
29
+ # **Tokens** are exact and nearly free: one hash lookup finds every name
30
+ # carrying the word. They are what finds `ABBAS, Abu` from `Abu Abbas`,
31
+ # since word order is not a thing a token index has an opinion about.
32
+ # They fail on a typo, on a transliterator's vowel, and on a token
33
+ # boundary drawn differently -- `AEROCARIBBEAN` shares no token at all
34
+ # with `AERO-CARIBBEAN`.
35
+ #
36
+ # **Trigrams** are what covers that. `GAZPROM` and `GAZPRON` share every
37
+ # trigram but two, and a name that lost a letter to a typo keeps almost
38
+ # all of them. They are also the only feature a non-Latin name has that
39
+ # survives a spelling difference, since the phonetic table cannot read it.
40
+ #
41
+ # **Phonetic keys** cover the case neither of the others can: two
42
+ # spellings with few letters in common that are the same name out loud.
43
+ # `QADDAFI` and `GADDAFI` share one trigram of five and no token, and one
44
+ # phonetic key.
45
+ module Features
46
+ extend T::Sig
47
+
48
+ # Three characters, which is the size that has to hold two properties at
49
+ # once: short enough that a name keeps most of its trigrams when a
50
+ # letter changes, and long enough that a shared trigram means something.
51
+ # Bigrams of names are close to noise -- `an`, `al` and `ar` are in a
52
+ # third of this corpus -- and quadgrams break too easily on the vowel a
53
+ # transliterator chose.
54
+ SIZE = T.let(3, Integer)
55
+
56
+ # Tokens are padded before they are cut up, so that the first and last
57
+ # letters of a word are inside a trigram that says they are first and
58
+ # last. Without it `ABBAS` and `SABBA` are the same bag of trigrams.
59
+ #
60
+ # A space is the padding because a folded token cannot contain one --
61
+ # Normalizer's stage 5 is what guarantees that -- so no padding trigram
62
+ # can collide with one from the middle of a word.
63
+ PAD = T.let(" ", String)
64
+
65
+ module_function
66
+
67
+ # The distinct tokens of a folded name.
68
+ #
69
+ # Distinct because a posting list is a set: `ALI, Ali Hassan` carries
70
+ # `ali` twice and is not twice as much of a match for it.
71
+ sig { params(form: Normalizer::Form).returns(T::Array[String]).checked(:tests) }
72
+ def tokens(form) = form.tokens.uniq
73
+
74
+ # Every character trigram of every token, padded at both ends.
75
+ #
76
+ # Per token rather than across the whole name: a trigram spanning two
77
+ # words would encode the order they were written in, and half of what
78
+ # this index exists to defeat is that order. `abbas abu` and `abu abbas`
79
+ # produce the same trigrams here, as they should.
80
+ sig { params(form: Normalizer::Form).returns(T::Array[String]).checked(:tests) }
81
+ def trigrams(form)
82
+ form.tokens.flat_map { |token| token_trigrams(token) }.uniq
83
+ end
84
+
85
+ sig { params(token: String).returns(T::Array[String]).checked(:tests) }
86
+ def token_trigrams(token)
87
+ padded = "#{PAD}#{token}#{PAD}"
88
+ length = padded.length
89
+ # A token of one character is shorter than a trigram once padded, and
90
+ # is indexed as the short string it is rather than not at all.
91
+ return [padded] if length <= SIZE
92
+
93
+ # Characters rather than bytes, for the reason Similarity.codepoints
94
+ # gives: a Cyrillic name has to be cut into the same number of pieces
95
+ # a Latin one of the same length is.
96
+ (0..(length - SIZE)).map { |offset| padded[offset, SIZE].to_s }
97
+ end
98
+
99
+ # Every Double Metaphone key of every token, both the primary and the
100
+ # alternate -- see Phonetics for why the second one is not optional.
101
+ #
102
+ # A token that produces no key contributes nothing rather than an empty
103
+ # one. An empty key would be a bucket holding every Cyrillic and Arabic
104
+ # name in the corpus, which is the largest and least useful posting list
105
+ # it is possible to build.
106
+ sig { params(form: Normalizer::Form).returns(T::Array[String]).checked(:tests) }
107
+ def phonetics(form)
108
+ form.tokens.flat_map { |token| Phonetics::DoubleMetaphone.call(token) }.uniq
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,381 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ # Which names are worth comparing at all. Stage 2 of the matching pipeline,
8
+ # and the difference between a library a service can call and one it cannot.
9
+ #
10
+ # index = ActiveSanction::Index.build(ActiveSanction.storage)
11
+ # index.size # => 46_218
12
+ #
13
+ # index.candidates("Abu Abbas").first.name.value # => "ABBAS, Abu"
14
+ # index.candidates("Abu Abbas", limit: 50).size # => 50
15
+ #
16
+ # ### Why an index rather than a scan
17
+ #
18
+ # The corpus is roughly 46,000 searchable name strings. Running the scorers
19
+ # over all of them costs a few hundred milliseconds per query in pure Ruby,
20
+ # which is fine for one call and hopeless for a service. Narrowing to a few
21
+ # hundred first is what puts a screening call in the ~10 ms range, and every
22
+ # cost decision in Similarity was made on the assumption that this stage
23
+ # exists.
24
+ #
25
+ # ### Recall is this stage's whole job
26
+ #
27
+ # A name this does not retrieve is never compared to anything. It cannot
28
+ # score badly; it does not appear. There is no later stage that can recover
29
+ # it and no signal to a caller that it happened -- a missed hit and a clean
30
+ # screening look identical from outside. So the bias here is the opposite of
31
+ # the scorers': retrieve generously, rank roughly, and let #32 be the one
32
+ # that says no.
33
+ #
34
+ # That is also why the cap is the dangerous part of this file. It is the one
35
+ # place a true match can be dropped silently, which is why `Candidate#weight`
36
+ # is exposed, why the default is set from a measured recall curve rather than
37
+ # a round number, and why the specs check recall against deliberately
38
+ # damaged queries rather than checking that retrieval merely works.
39
+ #
40
+ # ### One recall gap worth knowing about
41
+ #
42
+ # A query carrying *part* of a legal form is the one shape the recall
43
+ # harness found this stage weak on. `PUBLIC JOINT STOCK COMPANY GAZPROM` is
44
+ # indexed as `gazprom`, because the fold strips the legal form as a phrase;
45
+ # a query of `Gazprom Public Joint Stock` keeps all four words, since the
46
+ # phrase is not there to be matched. The query then looks more like every
47
+ # other long company name than like the short one it actually named, and the
48
+ # right entity can fall outside the cap -- about one in five, where every
49
+ # other damaged query shape measured above 0.99.
50
+ #
51
+ # It belongs to the fold rather than to the ranking, and is recorded here
52
+ # because this is where it shows up. Nothing in this file can fix it: an
53
+ # index can only look up what the fold produced.
54
+ #
55
+ # ### Three feature spaces, unioned
56
+ #
57
+ # Tokens, character trigrams and Double Metaphone keys -- see Features for
58
+ # what each is for and where each fails. A query is described the same way,
59
+ # and any name sharing any feature is a candidate.
60
+ #
61
+ # ### Ranking: rarity, and then length
62
+ #
63
+ # A name is a vector over the three feature spaces, each feature weighted by
64
+ # how rare it is -- `idf = log(1 + N/df)`, where `df` is how many indexed
65
+ # names carry the feature and `N` is how many there are -- and a candidate's
66
+ # weight is the cosine between its vector and the query's.
67
+ #
68
+ # **Rarity** is the first half, and this corpus is why. A quarter of the
69
+ # individuals on these lists share a handful of given names: `mohammed` is
70
+ # carried by thousands of names and says almost nothing about which one you
71
+ # meant, while a surname carried by two says nearly everything. Counting
72
+ # shared features equally would let a query for `Mohammed Al-Zawahiri` fill
73
+ # its candidate set with strangers called Mohammed and push the one Zawahiri
74
+ # off the end of the cap -- a false negative produced by a ranking choice,
75
+ # which is the worst way to produce one.
76
+ #
77
+ # **Length** is the second half, and it is not optional. Rarity alone is a
78
+ # sum, and a sum rewards having many features to add up: a query of four
79
+ # tokens carries about thirty trigrams, and twenty middling ones agreeing
80
+ # beat one rare token agreeing, however much rarer that token is. Measured
81
+ # rather than supposed -- it is what a recall harness caught this ranking
82
+ # doing, retrieving two hundred long organization names ahead of the short
83
+ # one the query actually named.
84
+ #
85
+ # Dividing by each side's norm is what fixes it, and it is the ordinary
86
+ # cosine: a candidate is measured on the *fraction* of itself the query
87
+ # accounts for, not on how much of it there is. A short name matched
88
+ # entirely outranks a long name matched partly, which is the behaviour a
89
+ # screening query wants.
90
+ #
91
+ # Together they also settle the three feature spaces against each other
92
+ # without a table of hand-set weights, which is the other thing a
93
+ # sum-of-counts cannot do.
94
+ #
95
+ # ### The cost of a query is bounded on purpose
96
+ #
97
+ # Walking a posting list is cheap per entry and there are lists with tens of
98
+ # thousands of entries in them. A query that walked every list it matched
99
+ # would spend most of its time on the features that tell it the least --
100
+ # exactly the ones the ranking is about to score near zero.
101
+ #
102
+ # So features are walked rarest first and stop at POSTINGS_BUDGET. What is
103
+ # dropped is always the least informative thing available, the rarest
104
+ # feature is always walked however common the query is, and the budget is
105
+ # what makes a query's cost a function of the cap rather than of how
106
+ # ordinary the name is. `Vladimir` and `Mohammed` cost the same as `Zawahiri`.
107
+ #
108
+ # ### Immutable, so a service can share one
109
+ #
110
+ # Everything here is frozen: the entries, the posting lists, the arrays
111
+ # inside them. A built index has no method that changes it, which is why a
112
+ # web process can hand the same one to every thread without a lock and why
113
+ # `candidates` can be called concurrently.
114
+ #
115
+ # A sync does not update an index. It builds a new one and the application
116
+ # swaps its reference:
117
+ #
118
+ # INDEX = Concurrent::AtomicReference.new(ActiveSanction::Index.build(store))
119
+ # # after a sync
120
+ # INDEX.set(ActiveSanction::Index.build(store))
121
+ #
122
+ # A plain `@index = ...` is enough on CRuby, where a reference assignment is
123
+ # atomic. Requests in flight keep the index they started with and finish
124
+ # against a consistent view of one list version, which is what makes a
125
+ # screening decision re-derivable: an index that mutated underneath a query
126
+ # would produce a result no snapshot checksum explains.
127
+ #
128
+ # @api private
129
+ class Index
130
+ extend T::Sig
131
+
132
+ # How many postings a query may walk before it stops adding features.
133
+ #
134
+ # Measured rather than chosen. Over a full-size corpus, 5,000 is the first
135
+ # budget at which a published name and an inverted one are both found
136
+ # every time, and raising it to 50,000 moves a typo's recall from 0.993 to
137
+ # 0.997 while the median query goes from 1.7 ms to 3.5 and the 99th
138
+ # percentile from 2.3 ms to 10.0. Lowering it to 2,500 starts losing names
139
+ # that were being found.
140
+ #
141
+ # The features a larger budget buys are the ones the cosine is about to
142
+ # weigh at nearly nothing, which is why it buys latency and almost no
143
+ # recall. `rake benchmark:index` prints that sweep and is how to take the
144
+ # numbers again on another machine.
145
+ POSTINGS_BUDGET = T.let(5_000, Integer)
146
+
147
+ # Entries, in the order they were indexed. The posting lists hold
148
+ # positions in this array.
149
+ sig { returns(T::Array[Entry]).checked(:tests) }
150
+ attr_reader :entries
151
+
152
+ # An index over every entity in a store, or over any enumerable of them:
153
+ #
154
+ # ActiveSanction::Index.build(ActiveSanction.storage)
155
+ # ActiveSanction::Index.build(store, sources: %i[ofac_sdn])
156
+ # ActiveSanction::Index.build(snapshot.entities)
157
+ #
158
+ # A store is streamed rather than materialized -- see Storage::Base#each_entity,
159
+ # which exists for this -- so building never holds every snapshot open at
160
+ # once.
161
+ sig { params(source: T.untyped, sources: T.untyped).returns(Index).checked(:tests) }
162
+ def self.build(source, sources: nil)
163
+ builder = Builder.new
164
+ each_entity(source, sources) { |entity| builder.add(entity) }
165
+ builder.build
166
+ end
167
+
168
+ sig { params(source: T.untyped, sources: T.untyped, block: T.proc.params(entity: T.untyped).void).void }
169
+ def self.each_entity(source, sources, &block)
170
+ return source.each_entity(sources: sources, &block) if source.respond_to?(:each_entity)
171
+ return source.each(&block) if source.respond_to?(:each)
172
+
173
+ raise InvalidArgument,
174
+ "cannot index #{source.class}: expected a storage adapter or an enumerable of entities"
175
+ end
176
+ private_class_method :each_entity
177
+
178
+ # Built by Builder, and the arguments are its internals: this takes
179
+ # ownership of them and freezes them.
180
+ sig do
181
+ params(entries: T::Array[Entry], tokens: T::Hash[String, T::Array[Integer]],
182
+ trigrams: T::Hash[String, T::Array[Integer]], phonetics: T::Hash[String, T::Array[Integer]])
183
+ .void.checked(:tests)
184
+ end
185
+ def initialize(entries:, tokens:, trigrams:, phonetics:)
186
+ @entries = T.let(entries.freeze, T::Array[Entry])
187
+ @tokens = T.let(seal(tokens), T::Hash[String, T::Array[Integer]])
188
+ @trigrams = T.let(seal(trigrams), T::Hash[String, T::Array[Integer]])
189
+ @phonetics = T.let(seal(phonetics), T::Hash[String, T::Array[Integer]])
190
+ @norms = T.let(norms.freeze, T::Array[Float])
191
+ freeze
192
+ end
193
+
194
+ # The names worth comparing to this one, most promising first.
195
+ #
196
+ # index.candidates("Abu Abbas")
197
+ # index.candidates(form, limit: 500, sources: %i[ofac_sdn])
198
+ #
199
+ # A String is folded here, under `type:` when the caller knows what kind
200
+ # of entity it is asking about -- which matters, because the fold's
201
+ # stoplists depend on it. A Form that has already been folded is taken as
202
+ # it stands, which is what a caller screening one name against several
203
+ # indexes should pass.
204
+ #
205
+ # `sources:` filters before the cap rather than after it. Filtering a
206
+ # capped list would silently return fewer names than asked for, and would
207
+ # do it precisely when the corpus is largest.
208
+ #
209
+ # An empty result means no indexed name shares a single token, trigram or
210
+ # phonetic key with the query. That is a real answer -- a name in a script
211
+ # nothing in the corpus is written in, most often -- and not an error.
212
+ sig do
213
+ params(query: T.untyped, limit: T.nilable(Integer), type: T.nilable(Symbol), sources: T.untyped)
214
+ .returns(T::Array[Candidate]).checked(:tests)
215
+ end
216
+ def candidates(query, limit: nil, type: nil, sources: nil)
217
+ form = query.is_a?(Normalizer::Form) ? query : Normalizer.call(query, type: type)
218
+ return [] if form.empty? || entries.empty?
219
+
220
+ walked = lists(form)
221
+ weights = accumulate(walked)
222
+ weights = keep(weights, sources) if sources
223
+ top(cosine(weights, walked), limit || ActiveSanction.config.candidate_limit)
224
+ end
225
+
226
+ # How many names are indexed. Names rather than entities: an entity with
227
+ # six aliases is six of these, because a comparison happens against one
228
+ # spelling at a time.
229
+ sig { returns(Integer).checked(:tests) }
230
+ def size = entries.size
231
+
232
+ sig { returns(T::Boolean).checked(:tests) }
233
+ def empty? = entries.empty?
234
+
235
+ # What the index is made of, for an operator endpoint and for the
236
+ # benchmark. Distinct features per space, and how many postings each holds
237
+ # -- which together are most of what the memory is.
238
+ sig { returns(T::Hash[Symbol, Integer]).checked(:tests) }
239
+ def stats
240
+ {
241
+ names: entries.size,
242
+ entities: entries.map { |entry| entry.entity.id }.uniq.size,
243
+ tokens: @tokens.size,
244
+ trigrams: @trigrams.size,
245
+ phonetics: @phonetics.size,
246
+ postings: [@tokens, @trigrams, @phonetics].sum { |space| space.sum { |_, ids| ids.size } }
247
+ }
248
+ end
249
+
250
+ sig { returns(String) }
251
+ def inspect = "#<#{self.class} #{size} names>"
252
+
253
+ private
254
+
255
+ sig { params(postings: T::Hash[String, T::Array[Integer]]).returns(T::Hash[String, T::Array[Integer]]) }
256
+ def seal(postings)
257
+ postings.each_value(&:freeze)
258
+ postings.freeze
259
+ end
260
+
261
+ # How much name each entry is, in the same units its features are weighted
262
+ # in: the length of its own vector. Computed once at build, because it
263
+ # cannot be computed before every posting list is complete -- a feature's
264
+ # rarity is a property of the finished corpus -- and because dividing by it
265
+ # is the difference between ranking a name and ranking its length.
266
+ sig { returns(T::Array[Float]) }
267
+ def norms
268
+ squares = Array.new(@entries.size, 0.0)
269
+ [@tokens, @trigrams, @phonetics].each do |space|
270
+ space.each_value do |ids|
271
+ square = idf(ids.size)**2
272
+ ids.each { |id| squares[id] += square }
273
+ end
274
+ end
275
+ squares.map! { |square| Math.sqrt(square) }
276
+ end
277
+
278
+ # What one feature carried by `df` of the corpus's names is worth.
279
+ sig { params(document_frequency: Integer).returns(Float) }
280
+ def idf(document_frequency) = Math.log(1.0 + (@entries.size / document_frequency.to_f))
281
+
282
+ # The posting lists this query will actually walk, rarest first, stopping
283
+ # at the budget. The first one is always taken: a query made entirely of
284
+ # common features still has to return something.
285
+ sig { params(form: Normalizer::Form).returns(T::Array[T::Array[Integer]]) }
286
+ def lists(form)
287
+ found = [[@tokens, Features.tokens(form)], [@trigrams, Features.trigrams(form)],
288
+ [@phonetics, Features.phonetics(form)]]
289
+ .flat_map { |space, features| features.filter_map { |feature| space[feature] } }
290
+ .sort_by(&:size)
291
+ walked = 0
292
+ found.take_while do |ids|
293
+ keep = walked.zero? || walked + ids.size <= POSTINGS_BUDGET
294
+ walked += ids.size
295
+ keep
296
+ end
297
+ end
298
+
299
+ # The un-normalized half of the cosine: for every candidate, the sum of
300
+ # `idf**2` over the features it shares with the query.
301
+ #
302
+ # The weight is computed once per posting list rather than once per
303
+ # posting, which matters: this loop runs tens of thousands of times per
304
+ # query, and a logarithm inside it would be most of the cost of a
305
+ # screening call.
306
+ sig { params(lists: T::Array[T::Array[Integer]]).returns(T::Hash[Integer, Float]) }
307
+ def accumulate(lists)
308
+ weights = Hash.new(0.0)
309
+ lists.each do |ids|
310
+ square = idf(ids.size)**2
311
+ ids.each { |id| weights[id] += square }
312
+ end
313
+ weights
314
+ end
315
+
316
+ # The other half: divide by both vectors' lengths.
317
+ #
318
+ # The query's norm is the same for every candidate and cannot change the
319
+ # order, but it is applied anyway, because it is what makes the result a
320
+ # cosine in 0..1 -- a number that means the same thing from one query to
321
+ # the next, rather than one that quietly scales with how long a name
322
+ # somebody typed.
323
+ #
324
+ # It is taken over the features actually walked rather than every feature
325
+ # the query has, so that a candidate agreeing with all of them scores
326
+ # exactly 1.0. What the budget dropped was not compared and does not
327
+ # belong in the denominator.
328
+ sig { params(weights: T::Hash[Integer, Float], lists: T::Array[T::Array[Integer]]).returns(T::Hash[Integer, Float]) }
329
+ def cosine(weights, lists)
330
+ query_norm = Math.sqrt(lists.sum { |ids| idf(ids.size)**2 })
331
+ return weights if query_norm.zero?
332
+
333
+ weights.each do |id, weight|
334
+ norm = @norms.fetch(id) * query_norm
335
+ # Clamped because a name matching a query exactly divides its own norm
336
+ # by itself, and floating point makes that 1.0000000000000002 often
337
+ # enough to matter to anything comparing against 1.
338
+ cosine = norm.positive? ? weight / norm : 0.0
339
+ weights[id] = [cosine, 1.0].min
340
+ end
341
+ end
342
+
343
+ sig { params(weights: T::Hash[Integer, Float], sources: T.untyped).returns(T::Hash[Integer, Float]) }
344
+ def keep(weights, sources)
345
+ wanted = Array(sources).to_set { |source| Sources::Definition.key!(source) }
346
+ weights.select { |id, _| wanted.include?(entries.fetch(id).source) }
347
+ end
348
+
349
+ # The heaviest `limit`, and a deterministic order among equals.
350
+ #
351
+ # Ties are not a corner case here, they are most of the list: every name
352
+ # that matched the query on the same features scores exactly the same
353
+ # Float, so the cap usually falls inside a group of equals. Which of them
354
+ # gets in has to be the same answer in a year's time -- a screening
355
+ # decision is re-derived during an audit -- so equals are ordered by id,
356
+ # which is the order the publisher listed them in.
357
+ #
358
+ # Sorting the whole thing by `[-weight, id]` says that in one line and
359
+ # costs about eight milliseconds a query, because comparing two-element
360
+ # arrays is a method call per comparison and there are tens of thousands
361
+ # of comparisons. So the k-th weight is found first, which is a C-level
362
+ # scan; everything above it is a list shorter than the cap, and everything
363
+ # equal to it is settled by taking the smallest ids. Same answer, about a
364
+ # tenth of the time.
365
+ sig { params(weights: T::Hash[Integer, Float], limit: Integer).returns(T::Array[Candidate]) }
366
+ def top(weights, limit)
367
+ return [] if weights.empty?
368
+
369
+ # Non-nil because `weights` is not empty, which is the line above.
370
+ cutoff = T.must(weights.values.max(limit).last)
371
+ above, tied = weights.keys.partition { |id| weights.fetch(id) > cutoff }
372
+ chosen = above.sort_by { |id| [-weights.fetch(id), id] } + tied.min(limit - above.size)
373
+ chosen.map { |id| Candidate.new(entry: entries.fetch(id), weight: weights.fetch(id)) }
374
+ end
375
+ end
376
+ end
377
+
378
+ require "active_sanction/index/features"
379
+ require "active_sanction/index/entry"
380
+ require "active_sanction/index/candidate"
381
+ require "active_sanction/index/builder"