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