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