active_sanction 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,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