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,183 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
class Doctor
|
|
10
|
+
# One thing a diagnosis noticed about one source.
|
|
11
|
+
#
|
|
12
|
+
# finding.source # => :ofac_sdn
|
|
13
|
+
# finding.severity # => :warn
|
|
14
|
+
# finding.check # => :remarks_coverage
|
|
15
|
+
# finding.observed # => 0.714
|
|
16
|
+
# finding.baseline # => 0.973
|
|
17
|
+
# finding.to_s # => "remarks coverage 71.4% (was 97.3%): \"Passport No.\" x 1880 unrecognized"
|
|
18
|
+
#
|
|
19
|
+
# ### Three severities, and what separates them
|
|
20
|
+
#
|
|
21
|
+
# :error this list is not what it was, and screening against it is
|
|
22
|
+
# unsafe -- it did not parse, it parsed to nothing, a column
|
|
23
|
+
# holds something else now, a field that every record carried
|
|
24
|
+
# is gone from all of them
|
|
25
|
+
# :warn a measurement moved further than a list of this kind moves in
|
|
26
|
+
# a day, and a human should look at it before the next sync
|
|
27
|
+
# :info something changed, or something is unrecognized, and it is
|
|
28
|
+
# within what these files do on their own
|
|
29
|
+
#
|
|
30
|
+
# The line between `error` and `warn` is not how big the number is. It is
|
|
31
|
+
# whether the reading can be explained by the list changing rather than by
|
|
32
|
+
# the *file* changing. A quarter of the records disappearing is a `warn`,
|
|
33
|
+
# because a delisting wave looks exactly like that and deciding which one
|
|
34
|
+
# it was is a judgment nothing here is entitled to make. A column that used
|
|
35
|
+
# to be numeric and is now full of company names is an `error`, because
|
|
36
|
+
# nothing a publisher does to its list can do that to its file.
|
|
37
|
+
#
|
|
38
|
+
# ### Observed and baseline are numbers, not prose
|
|
39
|
+
#
|
|
40
|
+
# `message` is written for a person reading a terminal at three in the
|
|
41
|
+
# morning. `observed` and `baseline` are for everything else: a threshold
|
|
42
|
+
# in a monitoring rule, a graph of a fill rate over ninety days, the
|
|
43
|
+
# instrumentation hooks (#59) a host application alerts through. Both are
|
|
44
|
+
# nil for a finding that is not a measurement -- a parse failure has no
|
|
45
|
+
# number.
|
|
46
|
+
#
|
|
47
|
+
# Instances are frozen on construction and compare by value.
|
|
48
|
+
class Finding
|
|
49
|
+
extend T::Sig
|
|
50
|
+
|
|
51
|
+
# Ordered, least serious first: comparing two severities is comparing
|
|
52
|
+
# their positions here.
|
|
53
|
+
SEVERITIES = T.let(%i[info warn error].freeze, T::Array[Symbol])
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
MEMBERS = T.let(%i[source severity check message observed baseline].freeze, T::Array[Symbol])
|
|
57
|
+
|
|
58
|
+
sig { returns(Symbol) }
|
|
59
|
+
attr_reader :source
|
|
60
|
+
|
|
61
|
+
# One of SEVERITIES.
|
|
62
|
+
sig { returns(Symbol) }
|
|
63
|
+
attr_reader :severity
|
|
64
|
+
|
|
65
|
+
# Which check produced this, as a stable machine name -- `:record_count`,
|
|
66
|
+
# `:fill_identifiers`, `:column_ent_num`. What a monitoring rule is
|
|
67
|
+
# written against, and what stays the same when the message is reworded.
|
|
68
|
+
sig { returns(Symbol) }
|
|
69
|
+
attr_reader :check
|
|
70
|
+
|
|
71
|
+
sig { returns(String) }
|
|
72
|
+
attr_reader :message
|
|
73
|
+
|
|
74
|
+
# What was measured this run, and what it was measured against. nil for a
|
|
75
|
+
# finding that is not a measurement, and `baseline` is nil as well for
|
|
76
|
+
# the first run of a source, where there was nothing to compare with.
|
|
77
|
+
sig { returns(T.untyped) }
|
|
78
|
+
attr_reader :observed
|
|
79
|
+
|
|
80
|
+
sig { returns(T.untyped) }
|
|
81
|
+
attr_reader :baseline
|
|
82
|
+
|
|
83
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
84
|
+
def self.from_h(hash)
|
|
85
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
86
|
+
unknown = attributes.keys - MEMBERS
|
|
87
|
+
raise InvalidArgument, "unknown Doctor::Finding attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
88
|
+
|
|
89
|
+
T.unsafe(self).new(**attributes)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Whether `first` is at least as serious as `second`.
|
|
93
|
+
sig { params(first: Symbol, second: Symbol).returns(T::Boolean) }
|
|
94
|
+
def self.at_least?(first, second)
|
|
95
|
+
SEVERITIES.index(first).to_i >= SEVERITIES.index(second).to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig do
|
|
99
|
+
params(source: T.untyped, severity: T.untyped, check: T.untyped, message: T.untyped,
|
|
100
|
+
observed: T.untyped, baseline: T.untyped).void
|
|
101
|
+
end
|
|
102
|
+
def initialize(source:, severity:, check:, message:, observed: nil, baseline: nil)
|
|
103
|
+
@source = T.let(symbol!(:source, source), Symbol)
|
|
104
|
+
@severity = T.let(severity!(severity), Symbol)
|
|
105
|
+
@check = T.let(symbol!(:check, check), Symbol)
|
|
106
|
+
@message = T.let(message!(message), String)
|
|
107
|
+
@observed = T.let(observed, T.untyped)
|
|
108
|
+
@baseline = T.let(baseline, T.untyped)
|
|
109
|
+
freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
sig { returns(T::Boolean) }
|
|
113
|
+
def error? = severity == :error
|
|
114
|
+
|
|
115
|
+
sig { returns(T::Boolean) }
|
|
116
|
+
def warn? = severity == :warn
|
|
117
|
+
|
|
118
|
+
sig { returns(T::Boolean) }
|
|
119
|
+
def info? = severity == :info
|
|
120
|
+
|
|
121
|
+
# Whether this finding is at least as serious as `level`.
|
|
122
|
+
sig { params(level: T.untyped).returns(T::Boolean) }
|
|
123
|
+
def at_least?(level) = Finding.at_least?(severity, symbol!(:severity, level))
|
|
124
|
+
|
|
125
|
+
# Whether there was anything to compare against. False says the reading
|
|
126
|
+
# was held to a committed floor rather than to the last sync.
|
|
127
|
+
sig { returns(T::Boolean) }
|
|
128
|
+
def compared? = !baseline.nil?
|
|
129
|
+
|
|
130
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
131
|
+
def to_h
|
|
132
|
+
{ source: source, severity: severity, check: check, message: message,
|
|
133
|
+
observed: observed, baseline: baseline }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { returns(String) }
|
|
137
|
+
def to_s = message
|
|
138
|
+
|
|
139
|
+
# The line the report prints under a source.
|
|
140
|
+
sig { returns(String) }
|
|
141
|
+
def to_line = " #{severity.to_s.ljust(5)} #{message}"
|
|
142
|
+
|
|
143
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
144
|
+
def ==(other)
|
|
145
|
+
return false unless other.instance_of?(self.class)
|
|
146
|
+
|
|
147
|
+
to_h == other.to_h
|
|
148
|
+
end
|
|
149
|
+
alias eql? ==
|
|
150
|
+
|
|
151
|
+
sig { returns(Integer) }
|
|
152
|
+
def hash = [self.class, to_h].hash
|
|
153
|
+
|
|
154
|
+
sig { returns(String) }
|
|
155
|
+
def inspect = "#<#{self.class} #{source} #{severity} #{check}: #{message}>"
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
160
|
+
def severity!(value)
|
|
161
|
+
severity = symbol!(:severity, value)
|
|
162
|
+
return severity if SEVERITIES.include?(severity)
|
|
163
|
+
|
|
164
|
+
raise InvalidArgument, "severity must be one of #{SEVERITIES.join(", ")}, got #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
168
|
+
def symbol!(member, value)
|
|
169
|
+
raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
|
|
170
|
+
|
|
171
|
+
value.to_sym
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sig { params(value: T.untyped).returns(String) }
|
|
175
|
+
def message!(value)
|
|
176
|
+
string = value.to_s.strip
|
|
177
|
+
raise InvalidArgument, "message is required" if string.empty?
|
|
178
|
+
|
|
179
|
+
-string
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/entity"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
class Doctor
|
|
11
|
+
# What one parse of one list measured, in numbers small enough to keep.
|
|
12
|
+
#
|
|
13
|
+
# profile = Doctor::Profile.measure(snapshot, adapter: source)
|
|
14
|
+
#
|
|
15
|
+
# profile.record_count # => 19015
|
|
16
|
+
# profile.cohorts[:individual] # => 11704
|
|
17
|
+
# profile.fill[:identifiers] # => 0.341
|
|
18
|
+
# profile.remarks_coverage # => 0.973
|
|
19
|
+
# profile.warnings # => { "unknown SDN_Type \"syndicate\"" => 41 }
|
|
20
|
+
#
|
|
21
|
+
# This is the thing a diagnosis compares. A snapshot is tens of megabytes
|
|
22
|
+
# and a profile of it is a few hundred bytes, so a host that wants to watch
|
|
23
|
+
# a fill rate over ninety days keeps ninety of these and no lists at all.
|
|
24
|
+
#
|
|
25
|
+
# ### Fill rates are the check that catches a clean parse of a changed file
|
|
26
|
+
#
|
|
27
|
+
# Record counts do not move when a publisher renames an element. Fill rates
|
|
28
|
+
# do: 19,321 entities carrying zero passports looks exactly like 19,321
|
|
29
|
+
# carrying 23,429 if the only thing anyone counts is records, and the first
|
|
30
|
+
# one screens a passport number against nothing. Measuring the share of
|
|
31
|
+
# records that carry each field is what turns that from invisible into a
|
|
32
|
+
# number that halved.
|
|
33
|
+
#
|
|
34
|
+
# ### Each field is measured over the records that could have one
|
|
35
|
+
#
|
|
36
|
+
# A date of birth is measured over individuals, because an organization
|
|
37
|
+
# never has one and including them would make the rate a function of how
|
|
38
|
+
# many companies a designation round happened to name. Everything else is
|
|
39
|
+
# measured over every record: an address, an identifier or a program is
|
|
40
|
+
# something any kind of listed party can carry, and a list where the
|
|
41
|
+
# organizations lost their registration numbers is the same failure as one
|
|
42
|
+
# where the people lost their passports.
|
|
43
|
+
#
|
|
44
|
+
# ### Half of a profile survives a stored snapshot, and half does not
|
|
45
|
+
#
|
|
46
|
+
# Everything derived from the entities -- counts, fill rates -- can be
|
|
47
|
+
# recomputed from a snapshot that was stored months ago, which is what
|
|
48
|
+
# makes the last sync usable as a baseline for free. Everything that falls
|
|
49
|
+
# out of the parse itself -- warnings, orphaned child rows, how much of
|
|
50
|
+
# OFAC's free text was understood, the column tallies -- exists only while
|
|
51
|
+
# the parse is running and is nil in a profile rebuilt from storage. A
|
|
52
|
+
# caller that wants those compared too keeps the profile: see
|
|
53
|
+
# Doctor#baseline.
|
|
54
|
+
#
|
|
55
|
+
# Instances are frozen on construction and compare by value.
|
|
56
|
+
class Profile
|
|
57
|
+
extend T::Sig
|
|
58
|
+
|
|
59
|
+
# Which records each field's fill rate is measured over. `:all` is every
|
|
60
|
+
# record in the list; a type name is that type alone. See the class
|
|
61
|
+
# comment.
|
|
62
|
+
#
|
|
63
|
+
# @api private
|
|
64
|
+
FIELDS = T.let({
|
|
65
|
+
names: :all,
|
|
66
|
+
aliases: :all,
|
|
67
|
+
addresses: :all,
|
|
68
|
+
identifiers: :all,
|
|
69
|
+
programs: :all,
|
|
70
|
+
remarks: :all,
|
|
71
|
+
dates_of_birth: :individual,
|
|
72
|
+
nationalities: :individual
|
|
73
|
+
}.freeze, T::Hash[Symbol, Symbol])
|
|
74
|
+
|
|
75
|
+
# How a field's fill rate reads in a sentence: "individuals with a date
|
|
76
|
+
# of birth 12% (was 61%)".
|
|
77
|
+
#
|
|
78
|
+
# @api private
|
|
79
|
+
COHORT_NAMES = T.let({ all: "records", individual: "individuals", organization: "organizations",
|
|
80
|
+
vessel: "vessels", aircraft: "aircraft" }.freeze, T::Hash[Symbol, String])
|
|
81
|
+
|
|
82
|
+
# The longest a shaped warning is kept at. A malformed row is frequently
|
|
83
|
+
# malformed because it is enormous, and the complaint about it carries a
|
|
84
|
+
# snippet.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
SHAPE_LENGTH = T.let(100, Integer)
|
|
88
|
+
|
|
89
|
+
# Unrecognized free-text shapes kept per profile. Enough to name what
|
|
90
|
+
# changed; not a histogram of a whole file.
|
|
91
|
+
#
|
|
92
|
+
# @api private
|
|
93
|
+
TOP_UNRECOGNIZED = T.let(5, Integer)
|
|
94
|
+
|
|
95
|
+
# @api private
|
|
96
|
+
MEMBERS = T.let(%i[
|
|
97
|
+
source record_count checksum cohorts fill warnings orphans remarks_coverage
|
|
98
|
+
unrecognized columns
|
|
99
|
+
].freeze, T::Array[Symbol])
|
|
100
|
+
|
|
101
|
+
sig { returns(Symbol) }
|
|
102
|
+
attr_reader :source
|
|
103
|
+
|
|
104
|
+
sig { returns(Integer) }
|
|
105
|
+
attr_reader :record_count
|
|
106
|
+
|
|
107
|
+
# The checksum of the snapshot this was measured over, so a stored
|
|
108
|
+
# profile can say which list version it describes.
|
|
109
|
+
sig { returns(T.nilable(String)) }
|
|
110
|
+
attr_reader :checksum
|
|
111
|
+
|
|
112
|
+
# How many records of each type, plus `:all`.
|
|
113
|
+
sig { returns(T::Hash[Symbol, Integer]) }
|
|
114
|
+
attr_reader :cohorts
|
|
115
|
+
|
|
116
|
+
# Field name to the share of its cohort carrying at least one, 0.0 to 1.0.
|
|
117
|
+
sig { returns(T::Hash[Symbol, Float]) }
|
|
118
|
+
attr_reader :fill
|
|
119
|
+
|
|
120
|
+
# Shaped parser warning to how many rows carried it. nil in a profile
|
|
121
|
+
# rebuilt from a stored snapshot -- see the class comment.
|
|
122
|
+
sig { returns(T.nilable(T::Hash[String, Integer])) }
|
|
123
|
+
attr_reader :warnings
|
|
124
|
+
|
|
125
|
+
# Child rows that matched no entity, by file. A nonzero count means the
|
|
126
|
+
# publisher's files were downloaded at different moments, or that the key
|
|
127
|
+
# they join on has moved.
|
|
128
|
+
sig { returns(T.nilable(T::Hash[Symbol, Integer])) }
|
|
129
|
+
attr_reader :orphans
|
|
130
|
+
|
|
131
|
+
# The share of the publisher's free text the adapter understood, for a
|
|
132
|
+
# source that reads any. nil for one that does not, and for a profile
|
|
133
|
+
# rebuilt from storage.
|
|
134
|
+
sig { returns(T.nilable(Float)) }
|
|
135
|
+
attr_reader :remarks_coverage
|
|
136
|
+
|
|
137
|
+
# The free-text shapes the parser did not recognize, to how many segments
|
|
138
|
+
# each cost -- what turns a coverage drop into the label that caused it.
|
|
139
|
+
sig { returns(T.nilable(T::Hash[String, Integer])) }
|
|
140
|
+
attr_reader :unrecognized
|
|
141
|
+
|
|
142
|
+
# Positional column assertions, as Parsers::ColumnShape::Tally#to_h wrote
|
|
143
|
+
# them. Empty for a source whose file names its own columns.
|
|
144
|
+
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
|
145
|
+
attr_reader :columns
|
|
146
|
+
|
|
147
|
+
# Measures a parsed snapshot, and everything the adapter that parsed it
|
|
148
|
+
# is willing to say about the parse. `adapter` is optional: without one
|
|
149
|
+
# this is the entity-derived half, which is exactly what a stored
|
|
150
|
+
# snapshot can supply.
|
|
151
|
+
sig { params(snapshot: T.untyped, adapter: T.untyped, columns: T.untyped).returns(T.attached_class) }
|
|
152
|
+
def self.measure(snapshot, adapter: nil, columns: nil)
|
|
153
|
+
entities = snapshot.entities
|
|
154
|
+
new(source: snapshot.source, record_count: snapshot.record_count, checksum: snapshot.checksum,
|
|
155
|
+
cohorts: count_cohorts(entities), fill: measure_fill(entities),
|
|
156
|
+
warnings: shape_warnings(adapter), orphans: count_orphans(adapter),
|
|
157
|
+
remarks_coverage: coverage_of(adapter), unrecognized: unrecognized_of(adapter),
|
|
158
|
+
columns: (columns || []).map(&:to_h))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
162
|
+
def self.from_h(hash)
|
|
163
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
164
|
+
unknown = attributes.keys - MEMBERS
|
|
165
|
+
raise InvalidArgument, "unknown Doctor::Profile attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
166
|
+
|
|
167
|
+
T.unsafe(self).new(**attributes)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# How many records of each type there are, and how many there are.
|
|
171
|
+
sig { params(entities: T::Array[T.untyped]).returns(T::Hash[Symbol, Integer]) }
|
|
172
|
+
def self.count_cohorts(entities)
|
|
173
|
+
counts = Entity::TYPES.to_h { |type| [type, 0] }
|
|
174
|
+
entities.each { |entity| counts[entity.type] = counts.fetch(entity.type, 0) + 1 }
|
|
175
|
+
counts.merge(all: entities.size)
|
|
176
|
+
end
|
|
177
|
+
private_class_method :count_cohorts
|
|
178
|
+
|
|
179
|
+
# One pass over the list, tallying every field against the cohort it is
|
|
180
|
+
# measured over. Fields whose cohort is empty are left out rather than
|
|
181
|
+
# recorded as zero -- a list with no individuals on it has no date of
|
|
182
|
+
# birth rate, and calling that 0% would report a regression the first
|
|
183
|
+
# time one was listed.
|
|
184
|
+
sig { params(entities: T::Array[T.untyped]).returns(T::Hash[Symbol, Float]) }
|
|
185
|
+
def self.measure_fill(entities)
|
|
186
|
+
filled = T.let(Hash.new(0), T::Hash[Symbol, Integer])
|
|
187
|
+
sizes = T.let(Hash.new(0), T::Hash[Symbol, Integer])
|
|
188
|
+
entities.each { |entity| tally_fields(entity, filled, sizes) }
|
|
189
|
+
sizes.reject { |_field, size| size.zero? }
|
|
190
|
+
.to_h { |field, size| [field, filled.fetch(field, 0).fdiv(size).round(4).to_f] }
|
|
191
|
+
end
|
|
192
|
+
private_class_method :measure_fill
|
|
193
|
+
|
|
194
|
+
sig do
|
|
195
|
+
params(entity: T.untyped, filled: T::Hash[Symbol, Integer], sizes: T::Hash[Symbol, Integer]).void
|
|
196
|
+
end
|
|
197
|
+
def self.tally_fields(entity, filled, sizes)
|
|
198
|
+
FIELDS.each do |field, cohort|
|
|
199
|
+
next unless cohort == :all || entity.type == cohort
|
|
200
|
+
|
|
201
|
+
sizes[field] = sizes.fetch(field, 0) + 1
|
|
202
|
+
filled[field] = filled.fetch(field, 0) + 1 if present?(entity, field)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
private_class_method :tally_fields
|
|
206
|
+
|
|
207
|
+
# Whether one record carries the field at all. `aliases` is the names
|
|
208
|
+
# beyond the primary one, which is its own signal: a list that stopped
|
|
209
|
+
# publishing alternate spellings still has a name on every record and is
|
|
210
|
+
# far harder to match against.
|
|
211
|
+
sig { params(entity: T.untyped, field: Symbol).returns(T::Boolean) }
|
|
212
|
+
def self.present?(entity, field)
|
|
213
|
+
case field
|
|
214
|
+
when :aliases then entity.names.any? { |name| !name.primary? }
|
|
215
|
+
when :remarks then !entity.remarks.nil? && !entity.remarks.empty?
|
|
216
|
+
else entity.public_send(field).any?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
private_class_method :present?
|
|
220
|
+
|
|
221
|
+
sig { params(adapter: T.untyped).returns(T.nilable(T::Hash[String, Integer])) }
|
|
222
|
+
def self.shape_warnings(adapter)
|
|
223
|
+
return nil unless adapter.respond_to?(:warnings)
|
|
224
|
+
|
|
225
|
+
adapter.warnings.each_with_object(T.let(Hash.new(0), T::Hash[String, Integer])) do |warning, shapes|
|
|
226
|
+
key = shape(warning.respond_to?(:message) ? warning.message : warning.to_s)
|
|
227
|
+
shapes[key] = shapes.fetch(key, 0) + 1
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
private_class_method :shape_warnings
|
|
231
|
+
|
|
232
|
+
sig { params(adapter: T.untyped).returns(T.nilable(T::Hash[Symbol, Integer])) }
|
|
233
|
+
def self.count_orphans(adapter)
|
|
234
|
+
return nil unless adapter.respond_to?(:orphans)
|
|
235
|
+
|
|
236
|
+
# An adapter reports orphans as the rows themselves; a count is
|
|
237
|
+
# accepted too, and `rows.size` cannot be asked of an Integer -- it
|
|
238
|
+
# answers with how many bytes wide it is.
|
|
239
|
+
adapter.orphans.to_h { |file, rows| [file.to_sym, rows.is_a?(Integer) ? rows : rows.to_a.size] }
|
|
240
|
+
end
|
|
241
|
+
private_class_method :count_orphans
|
|
242
|
+
|
|
243
|
+
sig { params(adapter: T.untyped).returns(T.nilable(Float)) }
|
|
244
|
+
def self.coverage_of(adapter)
|
|
245
|
+
return nil unless adapter.respond_to?(:remarks_coverage)
|
|
246
|
+
|
|
247
|
+
coverage = adapter.remarks_coverage
|
|
248
|
+
coverage.segments.zero? ? nil : coverage.ratio.round(4)
|
|
249
|
+
end
|
|
250
|
+
private_class_method :coverage_of
|
|
251
|
+
|
|
252
|
+
sig { params(adapter: T.untyped).returns(T.nilable(T::Hash[String, Integer])) }
|
|
253
|
+
def self.unrecognized_of(adapter)
|
|
254
|
+
return nil unless adapter.respond_to?(:remarks_coverage)
|
|
255
|
+
|
|
256
|
+
adapter.remarks_coverage.top(TOP_UNRECOGNIZED).to_h
|
|
257
|
+
end
|
|
258
|
+
private_class_method :unrecognized_of
|
|
259
|
+
|
|
260
|
+
# A warning's class, rather than the warning: the message with its digits
|
|
261
|
+
# masked, so "row 4711 has no SDN_Name" and "row 4712 has no SDN_Name"
|
|
262
|
+
# are one complaint counted twice rather than two complaints.
|
|
263
|
+
#
|
|
264
|
+
# Masking rather than truncating, because what distinguishes one class
|
|
265
|
+
# of warning from another on these lists is usually the value the
|
|
266
|
+
# publisher put in a field -- `unknown SDN_Type "syndicate"` is a
|
|
267
|
+
# different thing to notice from `unknown SDN_Type "trust"` -- while what
|
|
268
|
+
# makes two warnings the same complaint is that only their row numbers
|
|
269
|
+
# and identifiers differ.
|
|
270
|
+
sig { params(message: String).returns(String) }
|
|
271
|
+
def self.shape(message)
|
|
272
|
+
masked = message.gsub(/\d/, "#").gsub(/\s+/, " ").strip
|
|
273
|
+
-(masked.length > SHAPE_LENGTH ? "#{masked[0, SHAPE_LENGTH]}..." : masked)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
sig do
|
|
277
|
+
params(source: T.untyped, record_count: T.untyped, checksum: T.untyped, cohorts: T.untyped,
|
|
278
|
+
fill: T.untyped, warnings: T.untyped, orphans: T.untyped, remarks_coverage: T.untyped,
|
|
279
|
+
unrecognized: T.untyped, columns: T.untyped).void
|
|
280
|
+
end
|
|
281
|
+
def initialize(source:, record_count:, checksum: nil, cohorts: {}, fill: {}, warnings: nil,
|
|
282
|
+
orphans: nil, remarks_coverage: nil, unrecognized: nil, columns: [])
|
|
283
|
+
@source = T.let(symbol!(:source, source), Symbol)
|
|
284
|
+
@record_count = T.let(Integer(record_count), Integer)
|
|
285
|
+
@checksum = T.let(string_or_nil(checksum), T.nilable(String))
|
|
286
|
+
@cohorts = T.let(counts!(cohorts), T::Hash[Symbol, Integer])
|
|
287
|
+
@fill = T.let(ratios!(fill), T::Hash[Symbol, Float])
|
|
288
|
+
@warnings = T.let(warnings.nil? ? nil : tally!(warnings), T.nilable(T::Hash[String, Integer]))
|
|
289
|
+
@orphans = T.let(orphans.nil? ? nil : counts!(orphans), T.nilable(T::Hash[Symbol, Integer]))
|
|
290
|
+
@remarks_coverage = T.let(remarks_coverage.nil? ? nil : Float(remarks_coverage), T.nilable(Float))
|
|
291
|
+
@unrecognized = T.let(unrecognized.nil? ? nil : tally!(unrecognized), T.nilable(T::Hash[String, Integer]))
|
|
292
|
+
@columns = T.let(columns!(columns), T::Array[T::Hash[Symbol, T.untyped]])
|
|
293
|
+
freeze
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# How many records the field's rate was measured over, for a message that
|
|
297
|
+
# says "12% of 11,704 individuals" rather than "12%".
|
|
298
|
+
sig { params(field: Symbol).returns(Integer) }
|
|
299
|
+
def cohort_size(field) = cohorts.fetch(FIELDS.fetch(field, :all), 0)
|
|
300
|
+
|
|
301
|
+
# What to call the records a field was measured over: "individuals",
|
|
302
|
+
# "records".
|
|
303
|
+
sig { params(field: Symbol).returns(String) }
|
|
304
|
+
def cohort_name(field)
|
|
305
|
+
cohort = FIELDS.fetch(field, :all)
|
|
306
|
+
COHORT_NAMES.fetch(cohort, cohort.to_s)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Warning classes this parse produced, most rows first.
|
|
310
|
+
sig { params(count: Integer).returns(T::Array[T.untyped]) }
|
|
311
|
+
def top_warnings(count = 5)
|
|
312
|
+
(warnings || {}).sort_by { |shape, rows| [-rows, shape] }.first(count)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The unrecognized free-text shape that cost the most, as `[shape, count]`
|
|
316
|
+
# -- what a coverage finding names as the likely cause.
|
|
317
|
+
sig { returns(T.nilable(T::Array[T.untyped])) }
|
|
318
|
+
def worst_unrecognized
|
|
319
|
+
(unrecognized || {}).max_by { |shape, count| [count, shape] }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
sig { returns(Integer) }
|
|
323
|
+
def warning_count = (warnings || {}).values.sum
|
|
324
|
+
|
|
325
|
+
sig { returns(Integer) }
|
|
326
|
+
def orphan_count = (orphans || {}).values.sum
|
|
327
|
+
|
|
328
|
+
sig { returns(T::Boolean) }
|
|
329
|
+
def empty? = record_count.zero?
|
|
330
|
+
|
|
331
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
332
|
+
def to_h
|
|
333
|
+
{ source: source, record_count: record_count, checksum: checksum, cohorts: cohorts, fill: fill,
|
|
334
|
+
warnings: warnings, orphans: orphans, remarks_coverage: remarks_coverage,
|
|
335
|
+
unrecognized: unrecognized, columns: columns }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
339
|
+
def ==(other)
|
|
340
|
+
return false unless other.instance_of?(self.class)
|
|
341
|
+
|
|
342
|
+
to_h == other.to_h
|
|
343
|
+
end
|
|
344
|
+
alias eql? ==
|
|
345
|
+
|
|
346
|
+
sig { returns(Integer) }
|
|
347
|
+
def hash = [self.class, to_h].hash
|
|
348
|
+
|
|
349
|
+
sig { returns(String) }
|
|
350
|
+
def inspect = "#<#{self.class} #{source} #{record_count} records #{fill.size} fill rate(s)>"
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
355
|
+
def symbol!(member, value)
|
|
356
|
+
raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
|
|
357
|
+
|
|
358
|
+
value.to_sym
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
362
|
+
def string_or_nil(value)
|
|
363
|
+
return nil if value.nil?
|
|
364
|
+
|
|
365
|
+
string = value.to_s.strip
|
|
366
|
+
string.empty? ? nil : -string
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
sig { params(value: T.untyped).returns(T::Hash[Symbol, Integer]) }
|
|
370
|
+
def counts!(value) = value.to_h { |name, count| [name.to_sym, Integer(count)] }.freeze
|
|
371
|
+
|
|
372
|
+
sig { params(value: T.untyped).returns(T::Hash[String, Integer]) }
|
|
373
|
+
def tally!(value) = value.to_h { |name, count| [-name.to_s, Integer(count)] }.freeze
|
|
374
|
+
|
|
375
|
+
sig { params(value: T.untyped).returns(T::Hash[Symbol, Float]) }
|
|
376
|
+
def ratios!(value) = value.to_h { |name, ratio| [name.to_sym, Float(ratio)] }.freeze
|
|
377
|
+
|
|
378
|
+
sig { params(value: T.untyped).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
|
379
|
+
def columns!(value)
|
|
380
|
+
Array(value).map { |column| column.to_h.transform_keys(&:to_sym).freeze }.freeze
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|