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,208 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/entity"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
class Diff
|
|
10
|
+
# One entity that is on both snapshots and is not the same on each, with
|
|
11
|
+
# the fields that moved.
|
|
12
|
+
#
|
|
13
|
+
# change.entity # => Entity, as the new list has it
|
|
14
|
+
# change.previous # => Entity, as the old list had it
|
|
15
|
+
# change.fields # => [:names, :programs]
|
|
16
|
+
# change.changes
|
|
17
|
+
# # => { names: { added: [#<Name "ZAYDAN, Muhammad">], removed: [] },
|
|
18
|
+
# # programs: { added: ["SDGT"], removed: [] } }
|
|
19
|
+
#
|
|
20
|
+
# puts change # => ofac_sdn:2674 names +1, programs +1
|
|
21
|
+
#
|
|
22
|
+
# ### Why an amendment is not a delisting plus a listing
|
|
23
|
+
#
|
|
24
|
+
# Governments amend far more records than they publish or withdraw: a
|
|
25
|
+
# passport number is corrected, an alias is added, a program is amended.
|
|
26
|
+
# Reporting one of those as a removal followed by an addition puts a
|
|
27
|
+
# delisting in front of an analyst that never happened -- and a delisting
|
|
28
|
+
# is the entry a compliance team acts on, since it is the one that lets a
|
|
29
|
+
# customer back through the door. So the two snapshots are joined by entity
|
|
30
|
+
# id and only what actually moved is reported, which is what makes id
|
|
31
|
+
# stability a conformance requirement for every adapter (#16) rather than a
|
|
32
|
+
# nicety.
|
|
33
|
+
#
|
|
34
|
+
# ### Collections are compared as sets
|
|
35
|
+
#
|
|
36
|
+
# `names`, `addresses`, `identifiers`, `dates_of_birth`, `nationalities`
|
|
37
|
+
# and `programs` are compared by membership rather than position: a
|
|
38
|
+
# publisher that re-emits the same four aliases in a different order has
|
|
39
|
+
# not amended the record, and a diff that says it has costs somebody a
|
|
40
|
+
# review. Everything else is a scalar and is reported as `from` and `to`.
|
|
41
|
+
#
|
|
42
|
+
# `FIELDS` is derived from Entity::MEMBERS rather than written out, so a
|
|
43
|
+
# field added to the canonical record is compared here without anyone
|
|
44
|
+
# having to remember to add it. A new *collection* still has to be named in
|
|
45
|
+
# COLLECTIONS -- until it is, it is compared whole, which is a coarse
|
|
46
|
+
# answer rather than a silently missing one.
|
|
47
|
+
#
|
|
48
|
+
# Instances are frozen on construction and compare by value.
|
|
49
|
+
class Change
|
|
50
|
+
extend T::Sig
|
|
51
|
+
|
|
52
|
+
# Compared by membership. See the class comment.
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
COLLECTIONS = T.let(
|
|
56
|
+
%i[names addresses identifiers dates_of_birth nationalities programs].freeze,
|
|
57
|
+
T::Array[Symbol]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Every member of the canonical record except `id`, in the order Entity
|
|
61
|
+
# lays them out. `id` is the key the snapshots were joined on, so it
|
|
62
|
+
# cannot differ here; nothing else is excluded, including `source` --
|
|
63
|
+
# a record whose source moved under a stable id is a bug worth seeing
|
|
64
|
+
# rather than one worth hiding.
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
FIELDS = T.let((Entity::MEMBERS - %i[id]).freeze, T::Array[Symbol])
|
|
68
|
+
|
|
69
|
+
# How much of a scalar's value a summary line prints before it truncates.
|
|
70
|
+
# Remarks are prose and run to paragraphs.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
DISPLAY_WIDTH = T.let(40, Integer)
|
|
74
|
+
|
|
75
|
+
# The entity as the new snapshot has it.
|
|
76
|
+
sig { returns(T.untyped) }
|
|
77
|
+
attr_reader :entity
|
|
78
|
+
|
|
79
|
+
# The entity as the old snapshot had it.
|
|
80
|
+
sig { returns(T.untyped) }
|
|
81
|
+
attr_reader :previous
|
|
82
|
+
|
|
83
|
+
# Field to detail, in Entity's member order. A collection field carries
|
|
84
|
+
# `{ added:, removed: }` and a scalar `{ from:, to: }`.
|
|
85
|
+
sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
|
|
86
|
+
attr_reader :changes
|
|
87
|
+
|
|
88
|
+
# The change between two versions of one entity, or nil when they say the
|
|
89
|
+
# same thing. Nil rather than an empty change: "this record was amended"
|
|
90
|
+
# and "this record was re-published unchanged" are different answers, and
|
|
91
|
+
# only one of them is worth an analyst's time.
|
|
92
|
+
sig { params(previous: T.untyped, current: T.untyped).returns(T.nilable(T.attached_class)) }
|
|
93
|
+
def self.between(previous, current)
|
|
94
|
+
changes = FIELDS.each_with_object({}) do |field, found|
|
|
95
|
+
detail = compare(field, previous.public_send(field), current.public_send(field))
|
|
96
|
+
found[field] = detail if detail
|
|
97
|
+
end
|
|
98
|
+
changes.empty? ? nil : new(previous: previous, entity: current, changes: changes)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# What moved in one field, or nil if nothing did.
|
|
102
|
+
sig { params(field: Symbol, before: T.untyped, after: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
|
|
103
|
+
def self.compare(field, before, after)
|
|
104
|
+
return nil if before == after
|
|
105
|
+
return { from: before, to: after }.freeze unless COLLECTIONS.include?(field)
|
|
106
|
+
|
|
107
|
+
added = after - before
|
|
108
|
+
removed = before - after
|
|
109
|
+
# Equal as sets, unequal as arrays: the publisher reordered them.
|
|
110
|
+
return nil if added.empty? && removed.empty?
|
|
111
|
+
|
|
112
|
+
{ added: added.freeze, removed: removed.freeze }.freeze
|
|
113
|
+
end
|
|
114
|
+
private_class_method :compare
|
|
115
|
+
|
|
116
|
+
sig { params(previous: T.untyped, entity: T.untyped, changes: T.untyped).void }
|
|
117
|
+
def initialize(previous:, entity:, changes:)
|
|
118
|
+
@previous = T.let(previous, T.untyped)
|
|
119
|
+
@entity = T.let(entity, T.untyped)
|
|
120
|
+
@changes = T.let(changes.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
|
|
121
|
+
freeze
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The id both versions share, which is the whole reason this is one
|
|
125
|
+
# record rather than two.
|
|
126
|
+
sig { returns(String) }
|
|
127
|
+
def id = entity.id
|
|
128
|
+
|
|
129
|
+
sig { returns(T::Array[Symbol]) }
|
|
130
|
+
def fields = changes.keys
|
|
131
|
+
|
|
132
|
+
sig { params(field: T.untyped).returns(T::Boolean) }
|
|
133
|
+
def changed?(field) = changes.key?(field.to_sym)
|
|
134
|
+
|
|
135
|
+
# What moved in one field, or nil if that field did not.
|
|
136
|
+
sig { params(field: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
|
|
137
|
+
def [](field) = changes[field.to_sym]
|
|
138
|
+
|
|
139
|
+
# JSON-ready: every value object is serialized the way the snapshot
|
|
140
|
+
# serializes it, so a consumer that already reads entities can read a
|
|
141
|
+
# change without a second vocabulary.
|
|
142
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
143
|
+
def to_h
|
|
144
|
+
{
|
|
145
|
+
id: id,
|
|
146
|
+
entity: entity.to_h,
|
|
147
|
+
previous: previous.to_h,
|
|
148
|
+
changes: changes.transform_values { |detail| detail.transform_values { |value| serialize(value) } }
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# One line, for the summary a human reads:
|
|
153
|
+
#
|
|
154
|
+
# ofac_sdn:2674 names +1 -1, programs +1, remarks "..." -> "..."
|
|
155
|
+
sig { returns(String) }
|
|
156
|
+
def summary = fields.map { |field| describe(field) }.join(", ")
|
|
157
|
+
|
|
158
|
+
sig { returns(String) }
|
|
159
|
+
def to_s = "#{id} #{summary}"
|
|
160
|
+
|
|
161
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
162
|
+
def ==(other)
|
|
163
|
+
return false unless other.instance_of?(self.class)
|
|
164
|
+
|
|
165
|
+
to_h == other.to_h
|
|
166
|
+
end
|
|
167
|
+
alias eql? ==
|
|
168
|
+
|
|
169
|
+
sig { returns(Integer) }
|
|
170
|
+
def hash = [self.class, to_h].hash
|
|
171
|
+
|
|
172
|
+
sig { returns(String) }
|
|
173
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
sig { params(field: Symbol).returns(String) }
|
|
178
|
+
def describe(field)
|
|
179
|
+
detail = T.must(changes[field])
|
|
180
|
+
return "#{field} #{display(detail[:from])} -> #{display(detail[:to])}" unless COLLECTIONS.include?(field)
|
|
181
|
+
|
|
182
|
+
counts = [("+#{detail[:added].size}" unless detail[:added].empty?),
|
|
183
|
+
("-#{detail[:removed].size}" unless detail[:removed].empty?)]
|
|
184
|
+
"#{field} #{counts.compact.join(" ")}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A scalar as a summary line prints it. Long prose is truncated, since
|
|
188
|
+
# the question the line answers is which field moved and not what the
|
|
189
|
+
# whole of the new remarks say.
|
|
190
|
+
sig { params(value: T.untyped).returns(String) }
|
|
191
|
+
def display(value)
|
|
192
|
+
return "(none)" if value.nil?
|
|
193
|
+
|
|
194
|
+
text = value.to_s
|
|
195
|
+
text.length > DISPLAY_WIDTH ? "#{text[0, DISPLAY_WIDTH]}..." : text
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
199
|
+
def serialize(value)
|
|
200
|
+
case value
|
|
201
|
+
when Array then value.map { |item| serialize(item) }
|
|
202
|
+
when String, Symbol, Numeric, nil, true, false then value
|
|
203
|
+
else value.respond_to?(:to_h) ? value.to_h : value.to_s
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/snapshot"
|
|
8
|
+
require "active_sanction/sources"
|
|
9
|
+
require "active_sanction/storage"
|
|
10
|
+
require "active_sanction/storage/meta"
|
|
11
|
+
require "active_sanction/diff/change"
|
|
12
|
+
|
|
13
|
+
module ActiveSanction
|
|
14
|
+
# What changed between two snapshots of one source.
|
|
15
|
+
#
|
|
16
|
+
# diff = ActiveSanction.diff(:ofac_sdn, from: last_months_snapshot, to: todays_snapshot)
|
|
17
|
+
#
|
|
18
|
+
# diff.added # => [Entity], newly listed
|
|
19
|
+
# diff.removed # => [Entity], delisted
|
|
20
|
+
# diff.modified # => [Diff::Change], amended, with the fields that moved
|
|
21
|
+
# diff.changed # => [Entity], what a consuming service should re-screen against
|
|
22
|
+
# puts diff # => the summary below
|
|
23
|
+
#
|
|
24
|
+
# ### Why this exists
|
|
25
|
+
#
|
|
26
|
+
# Screening is not a one-time event. A customer cleared last month may be
|
|
27
|
+
# listed today, and the obligation is to notice. Re-running an entire book of
|
|
28
|
+
# business against an entire list every night is how most services answer
|
|
29
|
+
# that, and it is why most services answer it weekly instead. A diff turns
|
|
30
|
+
# the nightly job into "screen everyone against the eleven records that
|
|
31
|
+
# moved", which is a job small enough to run every time a list is synced.
|
|
32
|
+
#
|
|
33
|
+
# Delistings matter as much as listings, and they are the half a re-screen
|
|
34
|
+
# against added records only would miss: a delisting is what lets a customer
|
|
35
|
+
# back through the door, and a service that never notices one goes on
|
|
36
|
+
# blocking somebody the government stopped sanctioning in March.
|
|
37
|
+
#
|
|
38
|
+
# ### It rests on ids being stable
|
|
39
|
+
#
|
|
40
|
+
# The two snapshots are joined by entity id, so an amendment reports as one
|
|
41
|
+
# modification rather than as a delisting and a new listing. That only holds
|
|
42
|
+
# while a record keeps its id between syncs, which is why the adapter
|
|
43
|
+
# conformance group asserts id stability (#16) and why the Canada adapter
|
|
44
|
+
# (#22) hashes a citation *and* a name into a synthetic one. Ids that move
|
|
45
|
+
# would make every sync look like a full replacement, and a diff full of
|
|
46
|
+
# delistings that did not happen is worse than no diff at all.
|
|
47
|
+
#
|
|
48
|
+
# ### A first sync is a baseline, not 19,015 new listings
|
|
49
|
+
#
|
|
50
|
+
# With no previous snapshot there is nothing to compare, and reporting the
|
|
51
|
+
# whole list as `added` would be false: those records were not listed today,
|
|
52
|
+
# they were listed over twenty years and we are only now looking. So a diff
|
|
53
|
+
# with no `from` is a baseline -- `added`, `removed` and `modified` are all
|
|
54
|
+
# empty, `baseline?` is true, and `changed` is empty because the right
|
|
55
|
+
# response to a first sync is a deliberate full screening run rather than one
|
|
56
|
+
# driven by a diff that is really a list.
|
|
57
|
+
#
|
|
58
|
+
# ### Computed here rather than taken from a publisher
|
|
59
|
+
#
|
|
60
|
+
# OFAC serves a delta feed of its own at `/changes/latest`. This does not
|
|
61
|
+
# read it, and the reason is that a diff has to describe the two list
|
|
62
|
+
# versions *we hold*: a publisher's delta describes the change between two
|
|
63
|
+
# versions it chose, and a run that skipped a day, or held a stale list
|
|
64
|
+
# because a fetch failed (#34), is not on either end of it. Cross-checking a
|
|
65
|
+
# computed diff against that feed is worth doing -- it is how a parser
|
|
66
|
+
# regression that quietly drops records gets caught -- but it belongs in the
|
|
67
|
+
# OFAC adapter, as one publisher's answer to a question every source has to
|
|
68
|
+
# answer, rather than in the general shape of a diff.
|
|
69
|
+
#
|
|
70
|
+
# ### The output
|
|
71
|
+
#
|
|
72
|
+
# ofac_sdn: 19015 -> 19023 records, 12 added, 4 removed, 5 modified (0.1% of the previous list)
|
|
73
|
+
# + ofac_sdn:41234 IVANOV, Ivan Ivanovich [SDGT]
|
|
74
|
+
# - ofac_sdn:2674 ABBAS, Abu [SDGT]
|
|
75
|
+
# ~ ofac_sdn:36 names +1, programs +1
|
|
76
|
+
#
|
|
77
|
+
# That is `diff.to_s`, and `#summary` is its first line on its own -- the one
|
|
78
|
+
# a sync wrapper logs. There is no CLI to print either from, #36 being closed
|
|
79
|
+
# as not planned, so the human-readable form is a method on the object and a
|
|
80
|
+
# rake task or a scheduled job prints it. `#to_h` is the machine-readable form
|
|
81
|
+
# of the same thing, JSON-ready and carrying the two snapshots' checksums so a
|
|
82
|
+
# diff says which pair of list versions produced it.
|
|
83
|
+
#
|
|
84
|
+
# There is no `.from_h`: a diff is derived rather than stored, and those two
|
|
85
|
+
# checksums are what makes it reproducible -- keep them and the diff can
|
|
86
|
+
# always be computed again, keep the diff and you have a copy of an answer
|
|
87
|
+
# nobody can check.
|
|
88
|
+
#
|
|
89
|
+
# Instances are frozen on construction and compare by value.
|
|
90
|
+
class Diff
|
|
91
|
+
extend T::Sig
|
|
92
|
+
|
|
93
|
+
# How many detail lines #to_s prints before it stops. A real diff between
|
|
94
|
+
# two consecutive syncs is a handful of records; one that is thousands is a
|
|
95
|
+
# parse regression or a publisher who reissued a list under new ids, and
|
|
96
|
+
# neither is improved by dumping all of it into a terminal.
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
99
|
+
DETAIL_LIMIT = T.let(20, Integer)
|
|
100
|
+
|
|
101
|
+
sig { returns(Symbol) }
|
|
102
|
+
attr_reader :source
|
|
103
|
+
|
|
104
|
+
# Newly listed: on the new snapshot, not on the old, sorted by id.
|
|
105
|
+
sig { returns(T::Array[T.untyped]) }
|
|
106
|
+
attr_reader :added
|
|
107
|
+
|
|
108
|
+
# Delisted: on the old snapshot, not on the new.
|
|
109
|
+
sig { returns(T::Array[T.untyped]) }
|
|
110
|
+
attr_reader :removed
|
|
111
|
+
|
|
112
|
+
# Amended, with the fields that moved. See Diff::Change.
|
|
113
|
+
sig { returns(T::Array[Change]) }
|
|
114
|
+
attr_reader :modified
|
|
115
|
+
|
|
116
|
+
# What the old snapshot was: fetched_at, checksum, record_count. Nil for a
|
|
117
|
+
# baseline. The whole snapshot is deliberately not held -- a diff of eleven
|
|
118
|
+
# records would otherwise pin two lists and tens of megabytes of entities
|
|
119
|
+
# in memory for as long as anything holds it.
|
|
120
|
+
sig { returns(T.nilable(Storage::Meta)) }
|
|
121
|
+
attr_reader :from
|
|
122
|
+
|
|
123
|
+
# What the new snapshot is, which is what screening runs against now.
|
|
124
|
+
sig { returns(Storage::Meta) }
|
|
125
|
+
attr_reader :to
|
|
126
|
+
|
|
127
|
+
# Sugar, and what ActiveSanction.diff calls:
|
|
128
|
+
#
|
|
129
|
+
# ActiveSanction.diff(:ofac_sdn, from: old, to: new)
|
|
130
|
+
# ActiveSanction.diff(:ofac_sdn, from: old) # `to:` is what is stored now
|
|
131
|
+
# ActiveSanction.diff(from: old, to: new) # the source comes from the snapshots
|
|
132
|
+
#
|
|
133
|
+
# `to:` defaults to the stored snapshot because that is what a re-screen is
|
|
134
|
+
# about to run against, and it raises rather than defaulting to nothing:
|
|
135
|
+
# diffing against a list that is not there would report every record on it
|
|
136
|
+
# as delisted, which is a clean report for every customer on it.
|
|
137
|
+
sig { params(source: T.untyped, from: T.untyped, to: T.untyped, store: T.untyped).returns(T.attached_class) }
|
|
138
|
+
def self.call(source = nil, from: nil, to: nil, store: nil)
|
|
139
|
+
key = source.nil? ? nil : Sources::Definition.key!(source)
|
|
140
|
+
new(source: key, from: from, to: to || stored!(key, store))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { params(key: T.nilable(Symbol), store: T.untyped).returns(Snapshot) }
|
|
144
|
+
def self.stored!(key, store)
|
|
145
|
+
raise InvalidArgument, "diff needs a `to:` snapshot, or a source to read the current one from" if key.nil?
|
|
146
|
+
|
|
147
|
+
(store || ActiveSanction.storage).fetch_snapshot(key)
|
|
148
|
+
end
|
|
149
|
+
private_class_method :stored!
|
|
150
|
+
|
|
151
|
+
# `from:` is nil for a first sync, which is a baseline rather than a list
|
|
152
|
+
# of additions -- see the class comment. `source:` is optional and is
|
|
153
|
+
# checked against the snapshots rather than trusted, since a diff of the
|
|
154
|
+
# wrong pair of lists reports every record on both as having moved.
|
|
155
|
+
sig { params(to: T.untyped, from: T.untyped, source: T.untyped).void }
|
|
156
|
+
def initialize(to:, from: nil, source: nil)
|
|
157
|
+
current = snapshot!(:to, to)
|
|
158
|
+
previous = from.nil? ? nil : snapshot!(:from, from)
|
|
159
|
+
@source = T.let(source!(source, previous, current), Symbol)
|
|
160
|
+
@from = T.let(previous && Storage::Meta.from_snapshot(previous), T.nilable(Storage::Meta))
|
|
161
|
+
@to = T.let(Storage::Meta.from_snapshot(current), Storage::Meta)
|
|
162
|
+
added, removed, modified = compare(previous, current)
|
|
163
|
+
@added = T.let(added, T::Array[T.untyped])
|
|
164
|
+
@removed = T.let(removed, T::Array[T.untyped])
|
|
165
|
+
@modified = T.let(modified, T::Array[Change])
|
|
166
|
+
freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# No previous snapshot: the source was synced for the first time, and this
|
|
170
|
+
# says what it holds rather than claiming every record on it is new.
|
|
171
|
+
sig { returns(T::Boolean) }
|
|
172
|
+
def baseline? = from.nil?
|
|
173
|
+
|
|
174
|
+
sig { returns(T::Boolean) }
|
|
175
|
+
def empty? = added.empty? && removed.empty? && modified.empty?
|
|
176
|
+
|
|
177
|
+
sig { returns(T::Boolean) }
|
|
178
|
+
def any? = !empty?
|
|
179
|
+
|
|
180
|
+
# Records that moved, in either direction.
|
|
181
|
+
sig { returns(Integer) }
|
|
182
|
+
def size = added.size + removed.size + modified.size
|
|
183
|
+
|
|
184
|
+
# What a consuming service should re-screen its book against: the records
|
|
185
|
+
# that are on the list now and were not, or were not the same.
|
|
186
|
+
#
|
|
187
|
+
# Delistings are deliberately not in here -- they are not something to
|
|
188
|
+
# screen *against*, they are records to clear existing alerts on, which is
|
|
189
|
+
# a different job done from `removed`. And nothing here judges a change too
|
|
190
|
+
# small to matter: a corrected passport number and a reworded remark reach
|
|
191
|
+
# the scorer through different paths, and a library that decided on a
|
|
192
|
+
# host's behalf which amendments were worth re-screening would be deciding
|
|
193
|
+
# which sanctions hits it is willing to miss.
|
|
194
|
+
sig { returns(T::Array[T.untyped]) }
|
|
195
|
+
def changed = added + modified.map(&:entity)
|
|
196
|
+
|
|
197
|
+
# How much of the previous list moved, as a fraction. The number to alert
|
|
198
|
+
# on: two consecutive syncs of a live sanctions list move a fraction of a
|
|
199
|
+
# percent, so a diff that says a third of the list changed is a parse
|
|
200
|
+
# regression, an id scheme that shifted, or a publisher who reissued the
|
|
201
|
+
# file -- and all three are things to look at before re-screening anybody
|
|
202
|
+
# against the result. Nil for a baseline, and for a previous list that was
|
|
203
|
+
# empty.
|
|
204
|
+
sig { returns(T.nilable(Float)) }
|
|
205
|
+
def churn
|
|
206
|
+
count = from&.record_count
|
|
207
|
+
return nil if count.nil? || count.zero?
|
|
208
|
+
|
|
209
|
+
(size.to_f / count).round(6).to_f
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
213
|
+
def to_h
|
|
214
|
+
{
|
|
215
|
+
source: source,
|
|
216
|
+
from: from&.to_h,
|
|
217
|
+
to: to.to_h,
|
|
218
|
+
added: added.map(&:to_h),
|
|
219
|
+
removed: removed.map(&:to_h),
|
|
220
|
+
modified: modified.map(&:to_h)
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The one line at the top of #to_s, and the line worth logging on its own
|
|
225
|
+
# after a sync.
|
|
226
|
+
sig { returns(String) }
|
|
227
|
+
def summary
|
|
228
|
+
return "#{source}: first snapshot, #{to.record_count} records (baseline, nothing to re-screen)" if baseline?
|
|
229
|
+
return "#{source}: #{to.record_count} records, unchanged" if empty?
|
|
230
|
+
|
|
231
|
+
"#{source}: #{T.must(from).record_count} -> #{to.record_count} records, #{added.size} added, " \
|
|
232
|
+
"#{removed.size} removed, #{modified.size} modified#{churn_note}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# One line per record that moved, marked `+`, `-` or `~`. `limit:` caps how
|
|
236
|
+
# many are returned and adds a line saying how many were not; nil returns
|
|
237
|
+
# every one, which is what a consumer writing a report wants.
|
|
238
|
+
sig { params(limit: T.nilable(Integer)).returns(T::Array[String]) }
|
|
239
|
+
def details(limit: nil)
|
|
240
|
+
lines = added.map { |entity| " + #{label(entity)}" } +
|
|
241
|
+
removed.map { |entity| " - #{label(entity)}" } +
|
|
242
|
+
modified.map { |change| " ~ #{change}" }
|
|
243
|
+
return lines if limit.nil? || lines.size <= limit
|
|
244
|
+
|
|
245
|
+
lines.first(limit) + [" ... and #{lines.size - limit} more"]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
sig { returns(String) }
|
|
249
|
+
def to_s = ([summary] + details(limit: DETAIL_LIMIT)).join("\n")
|
|
250
|
+
|
|
251
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
252
|
+
def ==(other)
|
|
253
|
+
return false unless other.instance_of?(self.class)
|
|
254
|
+
|
|
255
|
+
to_h == other.to_h
|
|
256
|
+
end
|
|
257
|
+
alias eql? ==
|
|
258
|
+
|
|
259
|
+
sig { returns(Integer) }
|
|
260
|
+
def hash = [self.class, to_h].hash
|
|
261
|
+
|
|
262
|
+
sig { returns(String) }
|
|
263
|
+
def inspect = "#<#{self.class} #{source} +#{added.size} -#{removed.size} ~#{modified.size}>"
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# An entity as a summary line names it: its id, the name a hit would be
|
|
268
|
+
# reported under, and the programs it is listed under, which is the field
|
|
269
|
+
# that says what a hit on it means.
|
|
270
|
+
sig { params(entity: T.untyped).returns(String) }
|
|
271
|
+
def label(entity)
|
|
272
|
+
name = entity.primary_name&.value
|
|
273
|
+
programs = entity.programs
|
|
274
|
+
"#{entity.id} #{name || "(no name)"}#{" [#{programs.join(", ")}]" if programs.any?}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
sig { returns(String) }
|
|
278
|
+
def churn_note
|
|
279
|
+
fraction = churn
|
|
280
|
+
fraction.nil? ? "" : " (#{format("%.1f", fraction * 100)}% of the previous list)"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The three lists, or three empty ones for a baseline -- which is the whole
|
|
284
|
+
# of what "a first sync is not a list of additions" costs to implement.
|
|
285
|
+
sig { params(previous: T.nilable(Snapshot), current: Snapshot).returns(T::Array[T.untyped]) }
|
|
286
|
+
def compare(previous, current)
|
|
287
|
+
return [[], [], []] if previous.nil?
|
|
288
|
+
|
|
289
|
+
before = by_id(previous.entities)
|
|
290
|
+
after = by_id(current.entities)
|
|
291
|
+
[entities(after.keys - before.keys, after),
|
|
292
|
+
entities(before.keys - after.keys, before),
|
|
293
|
+
modifications(before, after)]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
sig { params(ids: T::Array[String], index: T::Hash[String, T.untyped]).returns(T::Array[T.untyped]) }
|
|
297
|
+
def entities(ids, index) = ids.sort.map { |id| index.fetch(id) }
|
|
298
|
+
|
|
299
|
+
sig do
|
|
300
|
+
params(before: T::Hash[String, T.untyped], after: T::Hash[String, T.untyped]).returns(T::Array[Change])
|
|
301
|
+
end
|
|
302
|
+
def modifications(before, after)
|
|
303
|
+
(before.keys & after.keys).sort.filter_map { |id| Change.between(before.fetch(id), after.fetch(id)) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# By id, and sorted output everywhere below, so that two runs over the same
|
|
307
|
+
# pair of lists produce the same diff whatever order the publisher happened
|
|
308
|
+
# to emit its file in.
|
|
309
|
+
#
|
|
310
|
+
# An id that appears twice in one snapshot keeps its first occurrence: the
|
|
311
|
+
# publisher's own file is what it is, the same rule is applied to both
|
|
312
|
+
# sides, and so a duplicate reads as unchanged rather than as a record that
|
|
313
|
+
# moved. Nothing here can resolve which of the two was meant, and refusing
|
|
314
|
+
# to diff a list that screens perfectly well would be the worse answer.
|
|
315
|
+
sig { params(list: T::Array[T.untyped]).returns(T::Hash[String, T.untyped]) }
|
|
316
|
+
def by_id(list)
|
|
317
|
+
list.each_with_object({}) do |entity, index|
|
|
318
|
+
unless entity.respond_to?(:id) && entity.respond_to?(:to_h)
|
|
319
|
+
raise InvalidArgument, "a diff compares entities, got #{entity.class}. A store that hands back " \
|
|
320
|
+
"half-deserialized records cannot be diffed -- see Storage::Base#read_snapshot"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
index[entity.id] ||= entity
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
sig { params(member: Symbol, value: T.untyped).returns(Snapshot) }
|
|
328
|
+
def snapshot!(member, value)
|
|
329
|
+
return value if value.is_a?(Snapshot)
|
|
330
|
+
|
|
331
|
+
raise InvalidArgument, "#{member} must be an ActiveSanction::Snapshot, got #{value.class}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# A diff of two different sources is not a diff, it is every record on both
|
|
335
|
+
# lists reported as having moved -- so the mismatch is refused rather than
|
|
336
|
+
# computed.
|
|
337
|
+
sig { params(named: T.untyped, previous: T.nilable(Snapshot), current: Snapshot).returns(Symbol) }
|
|
338
|
+
def source!(named, previous, current)
|
|
339
|
+
key = current.source
|
|
340
|
+
if previous && previous.source != key
|
|
341
|
+
raise InvalidArgument, "cannot diff a #{previous.source} snapshot against a #{key} one"
|
|
342
|
+
end
|
|
343
|
+
return key if named.nil? || named.to_sym == key
|
|
344
|
+
|
|
345
|
+
raise InvalidArgument, "asked for a #{named} diff, but the snapshots are #{key}"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|