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,279 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/normalizer/dictionary/stoplist"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
class Normalizer
|
|
10
|
+
# A name in both of the forms a screening decision needs: the string the
|
|
11
|
+
# publisher wrote, and the folded string a comparison actually runs
|
|
12
|
+
# against.
|
|
13
|
+
#
|
|
14
|
+
# form = ActiveSanction::Normalizer.call("O'Brien, Seán")
|
|
15
|
+
# form.original # => "O'Brien, Seán"
|
|
16
|
+
# form.value # => "o brien sean"
|
|
17
|
+
# form.tokens # => ["o", "brien", "sean"]
|
|
18
|
+
#
|
|
19
|
+
# org = ActiveSanction::Normalizer.call("Rosneft Oil Company", type: :organization)
|
|
20
|
+
# org.value # => "rosneft oil"
|
|
21
|
+
# org.type # => :organization
|
|
22
|
+
#
|
|
23
|
+
# Both halves travel together because both are needed at different ends of
|
|
24
|
+
# the same query. The scorers (#28, #29) compare `value`; the index (#31)
|
|
25
|
+
# keys on `tokens`; and what a compliance user reads in a hit is
|
|
26
|
+
# `original`, in the government's own capitalization and punctuation. A
|
|
27
|
+
# report that quotes the folded string instead is quoting this library
|
|
28
|
+
# rather than the list, which is not something anyone can take to an
|
|
29
|
+
# examiner.
|
|
30
|
+
#
|
|
31
|
+
# Instances are frozen on construction and compare by value.
|
|
32
|
+
#
|
|
33
|
+
# ### The pipeline
|
|
34
|
+
#
|
|
35
|
+
# Five stages, applied in this order to indexed names and query names
|
|
36
|
+
# alike -- see Normalizer for why that sameness is the whole point -- and
|
|
37
|
+
# a sixth that runs only for a caller who said what kind of entity the
|
|
38
|
+
# name belongs to:
|
|
39
|
+
#
|
|
40
|
+
# 1. **Unicode NFKD.** Decomposes `é` into `e` + combining acute, and folds
|
|
41
|
+
# the compatibility forms a publisher's export tooling emits: full-width
|
|
42
|
+
# `ABC` becomes `ABC`, `Ⅳ` becomes `IV`, `①` becomes `1`, and the
|
|
43
|
+
# no-break spaces scattered through the delimited lists become ordinary
|
|
44
|
+
# ones.
|
|
45
|
+
# 2. **Strip combining marks.** What stage 1 separated is dropped, so
|
|
46
|
+
# `Bélarus` is `belarus` and `Müller` is `muller`. Arabic gets the same
|
|
47
|
+
# treatment for free and wants it: the harakat are optional in writing,
|
|
48
|
+
# so one publisher's `مُحَمَّد` and another's `محمد` have to fold together, and
|
|
49
|
+
# `أ` decomposes to a bare alef rather than staying a third spelling of
|
|
50
|
+
# the same first letter.
|
|
51
|
+
# 3. **Casefold**, Unicode-aware: `String#downcase(:fold)` rather than
|
|
52
|
+
# `String#downcase`, which is what turns `Straße` into `strasse` instead
|
|
53
|
+
# of leaving a `ß` that no query will ever be typed with.
|
|
54
|
+
# 3b. **Transliterate the letters NFKD cannot help with** -- see
|
|
55
|
+
# TRANSLITERATIONS.
|
|
56
|
+
# 4. **Punctuation to spaces**, not to nothing: `Al-Qaida` is `al qaida`
|
|
57
|
+
# and `O'Brien` is `o brien`. Splitting is the conservative direction.
|
|
58
|
+
# A hyphen and a space are written interchangeably across these lists,
|
|
59
|
+
# so joining `Al-Qaida` into `alqaida` would make it unreachable from
|
|
60
|
+
# the `al qaida` a caller types, while splitting it leaves both sides as
|
|
61
|
+
# the same two tokens for the token ratios (#29) to work on.
|
|
62
|
+
# 5. **Collapse whitespace and strip**, which is what `tokens` is: the
|
|
63
|
+
# folded string split on whitespace, with `value` its single-spaced
|
|
64
|
+
# join.
|
|
65
|
+
# 6. **Drop the tokens that carry no identifying information**, given a
|
|
66
|
+
# Stoplist: `LTD` and `COMPANY` from an organization, `SHAYKH` from a
|
|
67
|
+
# person, nothing at all from either without one. Stage 6 is the only
|
|
68
|
+
# one that depends on something outside the string, which is why it
|
|
69
|
+
# arrives as an argument -- see Dictionary for what is on the lists and
|
|
70
|
+
# for the particles they may never touch.
|
|
71
|
+
#
|
|
72
|
+
# A name that folds away entirely keeps its unstripped tokens. An
|
|
73
|
+
# organization called "The Company" is a poor name to screen on and a
|
|
74
|
+
# worse one to index as the empty string, which matches everything or
|
|
75
|
+
# nothing depending on which scorer sees it first.
|
|
76
|
+
#
|
|
77
|
+
# ### What it deliberately does not do
|
|
78
|
+
#
|
|
79
|
+
# **Non-Latin script is not transliterated.** Cyrillic, Arabic, Han, Kana
|
|
80
|
+
# and Hangul come out of here casefolded and stripped of marks, in their
|
|
81
|
+
# own script. `Путин` does not become `putin`, so a Cyrillic name matches a
|
|
82
|
+
# Cyrillic query and nothing else. That is a real recall limitation, and it
|
|
83
|
+
# is stated rather than papered over.
|
|
84
|
+
#
|
|
85
|
+
# What makes it survivable is that these lists publish a non-Latin name as
|
|
86
|
+
# an additional variant rather than instead of a Latin one -- the UN's
|
|
87
|
+
# ORIGINAL_SCRIPT aliases and Canada's Cyrillic ones both sit on records
|
|
88
|
+
# that carry a romanized name too, which is the one an English-language
|
|
89
|
+
# query finds. Romanization itself is a per-script problem with several
|
|
90
|
+
# competing standards for Cyrillic alone, and guessing at it costs
|
|
91
|
+
# precision everywhere, so v1 does not. Double Metaphone (#30) covers the
|
|
92
|
+
# case this actually leaves open, which is one name romanized two ways.
|
|
93
|
+
#
|
|
94
|
+
# One consequence worth knowing: NFKD decomposes Hangul syllables into
|
|
95
|
+
# jamo, so `김정은` folds to its letters rather than its syllable blocks.
|
|
96
|
+
# Nothing downstream cares -- both sides of a comparison are folded the
|
|
97
|
+
# same way -- but the value is not the string a Korean reader would type.
|
|
98
|
+
class Form
|
|
99
|
+
extend T::Sig
|
|
100
|
+
|
|
101
|
+
# Nonspacing and enclosing marks: the diacritics stage 1 separated from
|
|
102
|
+
# their letters. Spacing marks (`Mc`) are deliberately left alone --
|
|
103
|
+
# those are the Indic vowel signs, which are letters in every sense that
|
|
104
|
+
# matters here, and dropping them would fold `का` and `कि` onto the same
|
|
105
|
+
# consonant.
|
|
106
|
+
#
|
|
107
|
+
# @api private
|
|
108
|
+
MARKS = T.let(/[\p{Mn}\p{Me}]/, Regexp)
|
|
109
|
+
|
|
110
|
+
# The Latin letters NFKD leaves untouched, because they are letters in
|
|
111
|
+
# their own right rather than a letter wearing a mark: nothing decomposes
|
|
112
|
+
# `ø` into an `o`. Without this table `Bjørn` and `Bjorn` are two
|
|
113
|
+
# different names, which is the same false negative diacritic stripping
|
|
114
|
+
# exists to prevent -- it just happens to affect Scandinavian, Polish,
|
|
115
|
+
# Turkish, Icelandic, Vietnamese and Azerbaijani names rather than French
|
|
116
|
+
# ones.
|
|
117
|
+
#
|
|
118
|
+
# Applied after casefolding, so the table only has to carry lowercase
|
|
119
|
+
# keys. `ß` is absent because casefolding already turns it into `ss`.
|
|
120
|
+
#
|
|
121
|
+
# @api private
|
|
122
|
+
TRANSLITERATIONS = T.let(
|
|
123
|
+
{
|
|
124
|
+
"æ" => "ae", "œ" => "oe", "ø" => "o", "ð" => "d", "þ" => "th",
|
|
125
|
+
"đ" => "d", "ħ" => "h", "ı" => "i", "ł" => "l", "ŀ" => "l",
|
|
126
|
+
"ŋ" => "n", "ŧ" => "t", "ĸ" => "k", "ə" => "e"
|
|
127
|
+
}.freeze,
|
|
128
|
+
T::Hash[String, String]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# @api private
|
|
132
|
+
TRANSLITERABLE = T.let(Regexp.union(TRANSLITERATIONS.keys), Regexp)
|
|
133
|
+
|
|
134
|
+
# Everything that is not a letter, a digit or whitespace, which covers
|
|
135
|
+
# the punctuation and the symbols in one rule and needs no list of
|
|
136
|
+
# dashes and quotation marks to be kept in step with reality.
|
|
137
|
+
#
|
|
138
|
+
# Plus the characters Unicode calls letters and a transliterator uses as
|
|
139
|
+
# punctuation: the spacing modifier letters, U+02B0 to U+02FF (`ʻ ʼ ʹ ʾ
|
|
140
|
+
# ʿ`), and the two glottal stop letters (`ʔ ʕ`). The UN list writes
|
|
141
|
+
# `Sanʻa` and `Qurʼan` with these where OFAC writes a plain apostrophe or
|
|
142
|
+
# nothing at all, and a mark that survives here is a token no query will
|
|
143
|
+
# ever be typed with.
|
|
144
|
+
#
|
|
145
|
+
# @api private
|
|
146
|
+
PUNCTUATION = T.let(/[^[:alnum:][:space:]]|[ʔʕʰ-˿]/, Regexp)
|
|
147
|
+
|
|
148
|
+
# The publisher's string, untouched. This is what a hit is reported in.
|
|
149
|
+
sig { returns(String).checked(:tests) }
|
|
150
|
+
attr_reader :original
|
|
151
|
+
|
|
152
|
+
# The folded form: lowercase, unmarked, punctuation-free, single-spaced.
|
|
153
|
+
# Empty when the original carried nothing a comparison can use -- see
|
|
154
|
+
# #empty?.
|
|
155
|
+
sig { returns(String).checked(:tests) }
|
|
156
|
+
attr_reader :value
|
|
157
|
+
|
|
158
|
+
# `value` split on whitespace. Frozen, and the array the token ratios
|
|
159
|
+
# (#29) and the inverted index (#31) read rather than splitting again per
|
|
160
|
+
# comparison.
|
|
161
|
+
sig { returns(T::Array[String]).checked(:tests) }
|
|
162
|
+
attr_reader :tokens
|
|
163
|
+
|
|
164
|
+
# The entity type this name was folded for, or nil when the caller did
|
|
165
|
+
# not say. It is what decides stage 6, and it travels with the form
|
|
166
|
+
# because two folds of the same string under different types are two
|
|
167
|
+
# different answers -- which is also why `value` is part of #==.
|
|
168
|
+
sig { returns(T.nilable(Symbol)).checked(:tests) }
|
|
169
|
+
attr_reader :type
|
|
170
|
+
|
|
171
|
+
# Untyped for the reason the rest of the model is: what arrives here is a
|
|
172
|
+
# publisher's text as whatever the parser made of it. Anything that
|
|
173
|
+
# responds to `to_s` works, which includes Name -- `Name#to_s` is its
|
|
174
|
+
# value -- so an indexer can hand over the object it already has.
|
|
175
|
+
#
|
|
176
|
+
# `stoplist` is stage 6, and comes from a Dictionary rather than from
|
|
177
|
+
# here: which tokens carry no information is a property of the entity
|
|
178
|
+
# type and of the lists in force, neither of which the string knows.
|
|
179
|
+
#
|
|
180
|
+
# Building a Form directly skips the cache; Normalizer.call is the entry
|
|
181
|
+
# point everything in the library goes through, and the one that resolves
|
|
182
|
+
# a type into the stoplist for it.
|
|
183
|
+
sig { params(original: T.untyped, stoplist: T.nilable(Dictionary::Stoplist)).void.checked(:tests) }
|
|
184
|
+
def initialize(original, stoplist: nil)
|
|
185
|
+
@original = T.let(-original.to_s, String)
|
|
186
|
+
@type = T.let(stoplist&.type, T.nilable(Symbol))
|
|
187
|
+
@tokens = T.let(fold(@original, stoplist), T::Array[String])
|
|
188
|
+
@value = T.let(-@tokens.join(" "), String)
|
|
189
|
+
freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# True when nothing survived the fold: a name of `"---"`, of punctuation,
|
|
193
|
+
# of an emoji, or of whitespace alone. It happens in real data, and it
|
|
194
|
+
# matters because such a name cannot be indexed and cannot be scored --
|
|
195
|
+
# every comparison against it is meaningless rather than merely bad. The
|
|
196
|
+
# index (#31) skips these; the alternative is a record that matches
|
|
197
|
+
# everything or nothing depending on which scorer sees it first.
|
|
198
|
+
sig { returns(T::Boolean).checked(:tests) }
|
|
199
|
+
def empty? = value.empty?
|
|
200
|
+
|
|
201
|
+
sig { returns(String).checked(:tests) }
|
|
202
|
+
def to_s = value
|
|
203
|
+
|
|
204
|
+
# Class is part of the comparison to keep #== and #hash agreeing, which
|
|
205
|
+
# is what Hash and Set rely on -- and the index is built out of both.
|
|
206
|
+
#
|
|
207
|
+
# Two forms are equal when they came from the same original and folded
|
|
208
|
+
# to the same value. The second half is not redundant now that stage 6
|
|
209
|
+
# exists: `value` is a pure function of the original *and* the stoplist,
|
|
210
|
+
# so "Rosneft Oil Company" folded as an organization and the same string
|
|
211
|
+
# folded as nothing in particular are two different answers rather than
|
|
212
|
+
# one.
|
|
213
|
+
#
|
|
214
|
+
# Note that this makes two differently-written names that fold to the
|
|
215
|
+
# same string unequal *as forms* while comparing as identical *for
|
|
216
|
+
# matching*, which is the distinction the whole pipeline rests on.
|
|
217
|
+
sig { params(other: T.untyped).returns(T::Boolean).checked(:tests) }
|
|
218
|
+
def ==(other)
|
|
219
|
+
return false unless other.instance_of?(self.class)
|
|
220
|
+
|
|
221
|
+
original == other.original && value == other.value
|
|
222
|
+
end
|
|
223
|
+
alias eql? ==
|
|
224
|
+
|
|
225
|
+
sig { returns(Integer).checked(:tests) }
|
|
226
|
+
def hash
|
|
227
|
+
[self.class, original, value].hash
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
sig { returns(String) }
|
|
231
|
+
def inspect
|
|
232
|
+
"#<#{self.class} #{original.inspect} => #{value.inspect}>"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
# The stages, in the order they have to run in: marks cannot be stripped
|
|
238
|
+
# before NFKD has separated them, the transliteration table only carries
|
|
239
|
+
# the lowercase keys casefolding produces, and the stoplist is written in
|
|
240
|
+
# the tokens the first five stages produce.
|
|
241
|
+
#
|
|
242
|
+
# Stripping every token is treated as stripping none. A name of nothing
|
|
243
|
+
# but legal forms is rare and real -- an organization called "The
|
|
244
|
+
# Company", a vessel whose only alias is its owner's suffix -- and the
|
|
245
|
+
# empty string is the one fold that cannot be scored at all.
|
|
246
|
+
sig do
|
|
247
|
+
params(string: String, stoplist: T.nilable(Dictionary::Stoplist))
|
|
248
|
+
.returns(T::Array[String]).checked(:tests)
|
|
249
|
+
end
|
|
250
|
+
def fold(string, stoplist)
|
|
251
|
+
decomposed = utf8(string).unicode_normalize(:nfkd).gsub(MARKS, "")
|
|
252
|
+
folded = decomposed.downcase(:fold).gsub(TRANSLITERABLE, TRANSLITERATIONS)
|
|
253
|
+
# `&:-@` is String#-@, the deduplicating freeze: 46,000 names share
|
|
254
|
+
# a few thousand distinct tokens between them, and the index holds
|
|
255
|
+
# onto every one of them.
|
|
256
|
+
tokens = folded.gsub(PUNCTUATION, " ").split.map(&:-@).freeze
|
|
257
|
+
return tokens if stoplist.nil?
|
|
258
|
+
|
|
259
|
+
kept = stoplist.reject(tokens)
|
|
260
|
+
kept.empty? ? tokens : kept.freeze
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# `unicode_normalize` raises on a string that is not valid UTF-8, and one
|
|
264
|
+
# record's stray byte is not worth failing an entire index build over --
|
|
265
|
+
# a list that will not build is a list nobody is screened against. The
|
|
266
|
+
# bad bytes become U+FFFD, which stage 4 turns into a space.
|
|
267
|
+
#
|
|
268
|
+
# Only the folded form is repaired. `original` keeps whatever arrived,
|
|
269
|
+
# because the point of keeping it is to show a compliance user exactly
|
|
270
|
+
# what the publisher's file said.
|
|
271
|
+
sig { params(string: String).returns(String).checked(:tests) }
|
|
272
|
+
def utf8(string)
|
|
273
|
+
return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
|
|
274
|
+
|
|
275
|
+
string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/normalizer/cache"
|
|
7
|
+
require "active_sanction/normalizer/dictionary"
|
|
8
|
+
require "active_sanction/normalizer/form"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
# The one place a name is folded into the form a comparison runs against.
|
|
12
|
+
#
|
|
13
|
+
# form = ActiveSanction::Normalizer.call("Bélarus")
|
|
14
|
+
# form.value # => "belarus"
|
|
15
|
+
# form.original # => "Bélarus"
|
|
16
|
+
#
|
|
17
|
+
# ActiveSanction::Normalizer.call("CO., LTD.").tokens # => ["co", "ltd"]
|
|
18
|
+
# ActiveSanction::Normalizer.call(name) # a Name works too
|
|
19
|
+
#
|
|
20
|
+
# ActiveSanction::Normalizer.call("PJSC Gazprom", type: :organization).value
|
|
21
|
+
# # => "gazprom"
|
|
22
|
+
#
|
|
23
|
+
# Stage 1 of the matching pipeline: everything a query is compared against
|
|
24
|
+
# has been through here, and so has the query. Form documents what the fold
|
|
25
|
+
# does and why each stage is there; this class is the entry point and the
|
|
26
|
+
# cache in front of it.
|
|
27
|
+
#
|
|
28
|
+
# ### Why one entry point rather than a method on each side
|
|
29
|
+
#
|
|
30
|
+
# Because a matcher whose index and query fold differently does not fail --
|
|
31
|
+
# it silently stops matching, on exactly the records the difference touches.
|
|
32
|
+
# If the indexer strips `'` and the query path does not, `O'Brien` is
|
|
33
|
+
# unreachable from `O'Brien`, the suite still passes, and the symptom is a
|
|
34
|
+
# sanctioned person reported clean. That is the most expensive bug this
|
|
35
|
+
# library can have and it is invisible from either side alone, so there is
|
|
36
|
+
# one code path and both sides call it. `Normalizer.call` is that path.
|
|
37
|
+
#
|
|
38
|
+
# It also means normalization is a versioned decision. Changing anything in
|
|
39
|
+
# Form changes every folded string in the library at once, which is what a
|
|
40
|
+
# stored index (#31) has to be rebuilt against and what a screening decision
|
|
41
|
+
# recorded under an older gem was made under -- see MatchResult's
|
|
42
|
+
# reproducibility stamp (#33).
|
|
43
|
+
#
|
|
44
|
+
# ### Instances
|
|
45
|
+
#
|
|
46
|
+
# `Normalizer.call` runs against DEFAULT, a process-wide instance whose cache
|
|
47
|
+
# is shared and internally synchronized. An instance exists as a seam rather
|
|
48
|
+
# than for configuration: a caller that wants its own cache -- a smaller one,
|
|
49
|
+
# or one it can discard after a batch -- builds `Normalizer.new`, and the
|
|
50
|
+
# fold it gets is identical.
|
|
51
|
+
#
|
|
52
|
+
# normalizer = ActiveSanction::Normalizer.new(cache_limit: 1_000)
|
|
53
|
+
# normalizer.call("Al-Qaida").value # => "al qaida"
|
|
54
|
+
#
|
|
55
|
+
# ### The dictionaries
|
|
56
|
+
#
|
|
57
|
+
# `type:` is what turns on stage 6, the pass that drops the tokens carrying
|
|
58
|
+
# no identifying information: `LTD` and `COMPANY` from an organization,
|
|
59
|
+
# `SHAYKH` from a person. It attaches here rather than to Form because the
|
|
60
|
+
# lists apply per entity type and are configurable, neither of which a string
|
|
61
|
+
# knows anything about; Dictionary is what is on them and why.
|
|
62
|
+
#
|
|
63
|
+
# Passing no type is not a lesser answer, it is a different question:
|
|
64
|
+
# "AERO-CARIBBEAN" as a bare string folds to `aero caribbean` whatever a
|
|
65
|
+
# dictionary says. A caller that has an Entity in hand should pass
|
|
66
|
+
# `entity.type`, and both sides of a comparison have to pass the same one --
|
|
67
|
+
# a query folded as an organization against an index folded as nothing is
|
|
68
|
+
# the same silent mismatch this class exists to prevent, one stage further
|
|
69
|
+
# down.
|
|
70
|
+
#
|
|
71
|
+
# A host's own lists reach the process-wide instance through configuration:
|
|
72
|
+
#
|
|
73
|
+
# ActiveSanction.configure do |c|
|
|
74
|
+
# c.normalizer_dictionary = { legal_forms: %w[OYJ TBK] }
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# An instance can pin one instead -- `Normalizer.new(dictionary:)` -- which
|
|
78
|
+
# is what makes a fold reproducible against a dictionary that is not the one
|
|
79
|
+
# the host configured.
|
|
80
|
+
class Normalizer
|
|
81
|
+
extend T::Sig
|
|
82
|
+
|
|
83
|
+
sig { returns(Cache) }
|
|
84
|
+
attr_reader :cache
|
|
85
|
+
|
|
86
|
+
sig { params(cache_limit: Integer, dictionary: T.nilable(Dictionary)).void }
|
|
87
|
+
def initialize(cache_limit: Cache::DEFAULT_LIMIT, dictionary: nil)
|
|
88
|
+
@cache = T.let(Cache.new(limit: cache_limit), Cache)
|
|
89
|
+
@dictionary = T.let(dictionary, T.nilable(Dictionary))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The pinned dictionary, or the configured one. Resolved per call rather
|
|
93
|
+
# than captured at construction because DEFAULT is built at load, which is
|
|
94
|
+
# before an application's initializer has run.
|
|
95
|
+
sig { returns(Dictionary) }
|
|
96
|
+
def dictionary = @dictionary || ActiveSanction.config.normalizer_dictionary
|
|
97
|
+
|
|
98
|
+
# The folded form of anything that responds to `to_s`, which is a String or
|
|
99
|
+
# a Name, optionally for a given entity type -- `:individual`,
|
|
100
|
+
# `:organization`, `:vessel`, `:aircraft`, or none.
|
|
101
|
+
#
|
|
102
|
+
# Idempotent in the sense the acceptance criterion asks for --
|
|
103
|
+
# `call(call(x).value, type:).value == call(x, type:).value` -- because the
|
|
104
|
+
# fold's output is already lowercase, unmarked, punctuation-free and
|
|
105
|
+
# single-spaced, and a stripped token cannot come back to be stripped
|
|
106
|
+
# again.
|
|
107
|
+
sig { params(value: T.untyped, type: T.nilable(Symbol)).returns(Form).checked(:tests) }
|
|
108
|
+
def call(value, type: nil)
|
|
109
|
+
string = value.to_s
|
|
110
|
+
stoplist = dictionary.stoplist(type)
|
|
111
|
+
return cache.fetch(string) { Form.new(string) } if stoplist.nil?
|
|
112
|
+
|
|
113
|
+
# The type and the lists in force are both part of the answer, so both
|
|
114
|
+
# are part of the key. The leading NUL is what keeps a composite key from
|
|
115
|
+
# colliding with the bare string of an untyped call; no name a publisher
|
|
116
|
+
# writes begins with one.
|
|
117
|
+
cache.fetch("\u0000#{stoplist.key}\u0000#{string}") { Form.new(string, stoplist: stoplist) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class << self
|
|
121
|
+
extend T::Sig
|
|
122
|
+
|
|
123
|
+
# Delegates to DEFAULT. This is the call site everything in the library
|
|
124
|
+
# uses; see the class comment for why there is only one.
|
|
125
|
+
sig { params(value: T.untyped, type: T.nilable(Symbol)).returns(Form).checked(:tests) }
|
|
126
|
+
def call(value, type: nil) = DEFAULT.call(value, type: type)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Built at load rather than memoized on first use, so nothing has to
|
|
130
|
+
# synchronize its construction. It holds a cache and a mutex, and reads its
|
|
131
|
+
# dictionary from the configuration on each call rather than holding one,
|
|
132
|
+
# which is why one process-wide instance is enough.
|
|
133
|
+
#
|
|
134
|
+
# @api private
|
|
135
|
+
DEFAULT = T.let(new, Normalizer)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Parsers
|
|
10
|
+
# An assertion about what one column of a positional file contains,
|
|
11
|
+
# measured over the whole file.
|
|
12
|
+
#
|
|
13
|
+
# ENT_NUM = ColumnShape.new(name: :ent_num, matches: /\A\d+\z/, at_least: 0.99)
|
|
14
|
+
#
|
|
15
|
+
# tally = ENT_NUM.tally(rows.map { |row| row[:ent_num] })
|
|
16
|
+
# tally.ok? # => false
|
|
17
|
+
# tally.ratio # => 0.0
|
|
18
|
+
# tally.sample # => ["AEROCARIBBEAN AIRLINES", "AEROTAXI EJECUTIVO"]
|
|
19
|
+
# tally.to_s # => "ent_num numeric on 0.0% of 19321 rows (expected 99%)"
|
|
20
|
+
#
|
|
21
|
+
# ### Why a declared width is not enough
|
|
22
|
+
#
|
|
23
|
+
# A file that names its own columns cannot have them quietly swapped: the
|
|
24
|
+
# header moves with the data and an adapter reading `sdn_type` still gets
|
|
25
|
+
# the type. A headerless file has no such protection, and OFAC ships three
|
|
26
|
+
# of them. Declaring the column names pins the *width*, so a column
|
|
27
|
+
# inserted upstream arrives as a wrong-width row and every row says so --
|
|
28
|
+
# but a column *reordered* upstream keeps the width, parses cleanly, and
|
|
29
|
+
# produces 19,321 entities built from shifted fields. Nothing raises,
|
|
30
|
+
# nothing warns, and the list means something different.
|
|
31
|
+
#
|
|
32
|
+
# So the shape of the values is asserted separately from the shape of the
|
|
33
|
+
# row. `ent_num` is a number on essentially every row of OFAC's file, and
|
|
34
|
+
# a version of that file where it is a company name is not a version this
|
|
35
|
+
# library should screen against.
|
|
36
|
+
#
|
|
37
|
+
# ### `at_least`, rather than "every row"
|
|
38
|
+
#
|
|
39
|
+
# These are published files, not validated ones. A single row where a
|
|
40
|
+
# publisher typed a letter into a numeric column is a curiosity; a file
|
|
41
|
+
# where a third of them are is a format change. The threshold is what
|
|
42
|
+
# separates the two, and it defaults to 99% -- high enough that a real
|
|
43
|
+
# swap cannot hide under it, loose enough that one bad row does not stop a
|
|
44
|
+
# sync being diagnosed as healthy.
|
|
45
|
+
#
|
|
46
|
+
# Blank values are not counted at all. A column the publisher leaves empty
|
|
47
|
+
# is saying nothing about its shape, and OFAC leaves most of its columns
|
|
48
|
+
# empty most of the time -- `-0- ` roughly a quarter of a million times
|
|
49
|
+
# across the six files. Counting those as failures would make every
|
|
50
|
+
# assertion about an optional column fail on the day it was written.
|
|
51
|
+
#
|
|
52
|
+
# Instances are frozen on construction.
|
|
53
|
+
class ColumnShape
|
|
54
|
+
extend T::Sig
|
|
55
|
+
|
|
56
|
+
# @api private
|
|
57
|
+
DEFAULT_AT_LEAST = T.let(0.99, Float)
|
|
58
|
+
|
|
59
|
+
# Unmatched values kept as evidence. Enough to recognize what is in the
|
|
60
|
+
# column instead, and few enough that a whole shifted file does not
|
|
61
|
+
# arrive in a report someone has to read.
|
|
62
|
+
#
|
|
63
|
+
# @api private
|
|
64
|
+
SAMPLE_SIZE = T.let(3, Integer)
|
|
65
|
+
|
|
66
|
+
sig { returns(Symbol) }
|
|
67
|
+
attr_reader :name
|
|
68
|
+
|
|
69
|
+
# The share of non-blank values that must satisfy the rule.
|
|
70
|
+
sig { returns(Float) }
|
|
71
|
+
attr_reader :at_least
|
|
72
|
+
|
|
73
|
+
# What the column is supposed to hold, in words, for the message a
|
|
74
|
+
# failure prints: "numeric", "a known SDN_Type".
|
|
75
|
+
sig { returns(String) }
|
|
76
|
+
attr_reader :description
|
|
77
|
+
|
|
78
|
+
# One of `matches:` (a Regexp), `allowing:` (the values the column may
|
|
79
|
+
# hold, compared case-insensitively after stripping), or `satisfying:`
|
|
80
|
+
# (a callable taking the value and returning truthy). `description:`
|
|
81
|
+
# names the expectation in the failure message; the first two derive a
|
|
82
|
+
# readable one when it is not given.
|
|
83
|
+
sig do
|
|
84
|
+
params(name: T.untyped, matches: T.untyped, allowing: T.untyped, satisfying: T.untyped,
|
|
85
|
+
at_least: T.untyped, description: T.untyped).void
|
|
86
|
+
end
|
|
87
|
+
def initialize(name:, matches: nil, allowing: nil, satisfying: nil, at_least: DEFAULT_AT_LEAST,
|
|
88
|
+
description: nil)
|
|
89
|
+
@name = T.let(name!(name), Symbol)
|
|
90
|
+
@allowed = T.let(allowing.nil? ? nil : allowed!(allowing), T.nilable(T::Array[String]))
|
|
91
|
+
@rule = T.let(rule!(matches, satisfying), T.nilable(T.proc.params(value: String).returns(T.untyped)))
|
|
92
|
+
@at_least = T.let(at_least!(at_least), Float)
|
|
93
|
+
@description = T.let(description!(description, matches), String)
|
|
94
|
+
freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether one value satisfies the assertion. Blanks never reach here --
|
|
98
|
+
# see #tally.
|
|
99
|
+
sig { params(value: String).returns(T::Boolean) }
|
|
100
|
+
def satisfied_by?(value)
|
|
101
|
+
allowed = @allowed
|
|
102
|
+
return allowed.include?(value.strip.downcase) unless allowed.nil?
|
|
103
|
+
|
|
104
|
+
!!T.must(@rule).call(value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Measures the assertion over one file's worth of values, in one pass.
|
|
108
|
+
# Takes anything enumerable, so a caller can hand it a lazy reader
|
|
109
|
+
# rather than materializing 19,321 rows.
|
|
110
|
+
sig { params(values: T.untyped).returns(Tally) }
|
|
111
|
+
def tally(values)
|
|
112
|
+
checked = 0
|
|
113
|
+
matched = 0
|
|
114
|
+
blank = 0
|
|
115
|
+
sample = T.let([], T::Array[String])
|
|
116
|
+
values.each do |value|
|
|
117
|
+
string = value.nil? ? "" : value.to_s.strip
|
|
118
|
+
next blank += 1 if string.empty?
|
|
119
|
+
|
|
120
|
+
checked += 1
|
|
121
|
+
next matched += 1 if satisfied_by?(string)
|
|
122
|
+
|
|
123
|
+
sample << string if sample.size < SAMPLE_SIZE
|
|
124
|
+
end
|
|
125
|
+
Tally.new(shape: self, checked: checked, matched: matched, blank: blank, sample: sample)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sig { returns(String) }
|
|
129
|
+
def to_s = "#{name} #{description} on at least #{percentage(at_least)} of rows"
|
|
130
|
+
|
|
131
|
+
sig { returns(String) }
|
|
132
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
133
|
+
|
|
134
|
+
# A percentage as a report prints one: no decimal where there is nothing
|
|
135
|
+
# after the point, since "99%" is what was declared and "99.0%" is not.
|
|
136
|
+
sig { params(ratio: Float).returns(String) }
|
|
137
|
+
def self.percentage(ratio)
|
|
138
|
+
value = (ratio * 100).round(1)
|
|
139
|
+
value == value.to_i ? "#{value.to_i}%" : "#{value}%"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig { params(ratio: Float).returns(String) }
|
|
143
|
+
def percentage(ratio) = ColumnShape.percentage(ratio)
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
148
|
+
def name!(value)
|
|
149
|
+
string = value.to_s.strip
|
|
150
|
+
raise InvalidArgument, "a column shape needs a column name" if string.empty?
|
|
151
|
+
|
|
152
|
+
string.to_sym
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
156
|
+
def allowed!(value)
|
|
157
|
+
list = Array(value).map { |entry| entry.to_s.strip.downcase }.reject(&:empty?)
|
|
158
|
+
raise InvalidArgument, "allowing: needs at least one value" if list.empty?
|
|
159
|
+
|
|
160
|
+
list.uniq.freeze
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
sig do
|
|
164
|
+
params(matches: T.untyped, satisfying: T.untyped)
|
|
165
|
+
.returns(T.nilable(T.proc.params(value: String).returns(T.untyped)))
|
|
166
|
+
end
|
|
167
|
+
def rule!(matches, satisfying)
|
|
168
|
+
return nil unless @allowed.nil?
|
|
169
|
+
return ->(value) { matches.match?(value) } if matches.is_a?(Regexp)
|
|
170
|
+
return ->(value) { satisfying.call(value) } if satisfying.respond_to?(:call)
|
|
171
|
+
|
|
172
|
+
raise InvalidArgument,
|
|
173
|
+
"a column shape needs one of matches: (a Regexp), allowing: (the values it may hold) " \
|
|
174
|
+
"or satisfying: (a callable)"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
sig { params(value: T.untyped).returns(Float) }
|
|
178
|
+
def at_least!(value)
|
|
179
|
+
ratio = Float(value)
|
|
180
|
+
return ratio if ratio.between?(0.0, 1.0)
|
|
181
|
+
|
|
182
|
+
raise InvalidArgument, "at_least must be a share between 0 and 1, got #{value.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
sig { params(value: T.untyped, matches: T.untyped).returns(String) }
|
|
186
|
+
def description!(value, matches)
|
|
187
|
+
string = value.to_s.strip
|
|
188
|
+
return -string unless string.empty?
|
|
189
|
+
return -"one of #{@allowed.join(", ")}" unless @allowed.nil?
|
|
190
|
+
|
|
191
|
+
matches.is_a?(Regexp) ? -"matching #{matches.inspect}" : "as declared"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# What a ColumnShape measured over one file. Frozen, serializable, and
|
|
195
|
+
# deliberately keeping the values it rejected: "ent_num is numeric on 0%
|
|
196
|
+
# of rows" says a column moved, and the sample says which one moved into
|
|
197
|
+
# it.
|
|
198
|
+
#
|
|
199
|
+
# @api private
|
|
200
|
+
class Tally
|
|
201
|
+
extend T::Sig
|
|
202
|
+
|
|
203
|
+
sig { returns(ColumnShape) }
|
|
204
|
+
attr_reader :shape
|
|
205
|
+
|
|
206
|
+
# Non-blank values measured.
|
|
207
|
+
sig { returns(Integer) }
|
|
208
|
+
attr_reader :checked
|
|
209
|
+
|
|
210
|
+
sig { returns(Integer) }
|
|
211
|
+
attr_reader :matched
|
|
212
|
+
|
|
213
|
+
# Values the publisher left empty, which are not measured -- see the
|
|
214
|
+
# ColumnShape comment.
|
|
215
|
+
sig { returns(Integer) }
|
|
216
|
+
attr_reader :blank
|
|
217
|
+
|
|
218
|
+
# Up to ColumnShape::SAMPLE_SIZE of the values that did not satisfy the
|
|
219
|
+
# assertion, as evidence.
|
|
220
|
+
sig { returns(T::Array[String]) }
|
|
221
|
+
attr_reader :sample
|
|
222
|
+
|
|
223
|
+
sig do
|
|
224
|
+
params(shape: ColumnShape, checked: Integer, matched: Integer, blank: Integer,
|
|
225
|
+
sample: T::Array[String]).void
|
|
226
|
+
end
|
|
227
|
+
def initialize(shape:, checked:, matched:, blank: 0, sample: [])
|
|
228
|
+
@shape = T.let(shape, ColumnShape)
|
|
229
|
+
@checked = T.let(checked, Integer)
|
|
230
|
+
@matched = T.let(matched, Integer)
|
|
231
|
+
@blank = T.let(blank, Integer)
|
|
232
|
+
@sample = T.let(sample.dup.freeze, T::Array[String])
|
|
233
|
+
freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
sig { returns(Symbol) }
|
|
237
|
+
def name = shape.name
|
|
238
|
+
|
|
239
|
+
# 1.0 for a column with nothing in it to measure. A file where the
|
|
240
|
+
# column is blank on every row is a different complaint -- a fill rate
|
|
241
|
+
# that fell to zero -- and reporting it here as a shape violation would
|
|
242
|
+
# say the wrong thing about it twice.
|
|
243
|
+
sig { returns(Float) }
|
|
244
|
+
def ratio = checked.zero? ? 1.0 : matched.fdiv(checked)
|
|
245
|
+
|
|
246
|
+
sig { returns(T::Boolean) }
|
|
247
|
+
def ok? = ratio >= shape.at_least
|
|
248
|
+
|
|
249
|
+
sig { returns(T::Boolean) }
|
|
250
|
+
def failed? = !ok?
|
|
251
|
+
|
|
252
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
253
|
+
def to_h
|
|
254
|
+
{ column: name, description: shape.description, checked: checked, matched: matched,
|
|
255
|
+
blank: blank, ratio: ratio.round(4), at_least: shape.at_least, sample: sample }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
sig { returns(String) }
|
|
259
|
+
def to_s
|
|
260
|
+
"#{name} #{shape.description} on #{shape.percentage(ratio)} of #{checked} rows " \
|
|
261
|
+
"(expected #{shape.percentage(shape.at_least)})#{evidence}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
sig { returns(String) }
|
|
265
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
sig { returns(String) }
|
|
270
|
+
def evidence = sample.empty? ? "" : ": #{sample.map(&:inspect).join(", ")}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|