Class: ActiveSanction::Diff
- Inherits:
-
Object
- Object
- ActiveSanction::Diff
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/diff.rb,
lib/active_sanction/diff/change.rb
Overview
What changed between two snapshots of one source.
diff = ActiveSanction.diff(:ofac_sdn, from: last_months_snapshot, to: todays_snapshot)
diff.added # => [Entity], newly listed
diff.removed # => [Entity], delisted
diff.modified # => [Diff::Change], amended, with the fields that moved
diff.changed # => [Entity], what a consuming service should re-screen against
puts diff # => the summary below
Why this exists
Screening is not a one-time event. A customer cleared last month may be listed today, and the obligation is to notice. Re-running an entire book of business against an entire list every night is how most services answer that, and it is why most services answer it weekly instead. A diff turns the nightly job into "screen everyone against the eleven records that moved", which is a job small enough to run every time a list is synced.
Delistings matter as much as listings, and they are the half a re-screen against added records only would miss: a delisting is what lets a customer back through the door, and a service that never notices one goes on blocking somebody the government stopped sanctioning in March.
It rests on ids being stable
The two snapshots are joined by entity id, so an amendment reports as one modification rather than as a delisting and a new listing. That only holds while a record keeps its id between syncs, which is why the adapter conformance group asserts id stability (#16) and why the Canada adapter (#22) hashes a citation and a name into a synthetic one. Ids that move would make every sync look like a full replacement, and a diff full of delistings that did not happen is worse than no diff at all.
A first sync is a baseline, not 19,015 new listings
With no previous snapshot there is nothing to compare, and reporting the
whole list as added would be false: those records were not listed today,
they were listed over twenty years and we are only now looking. So a diff
with no from is a baseline -- added, removed and modified are all
empty, baseline? is true, and changed is empty because the right
response to a first sync is a deliberate full screening run rather than one
driven by a diff that is really a list.
Computed here rather than taken from a publisher
OFAC serves a delta feed of its own at /changes/latest. This does not
read it, and the reason is that a diff has to describe the two list
versions we hold: a publisher's delta describes the change between two
versions it chose, and a run that skipped a day, or held a stale list
because a fetch failed (#34), is not on either end of it. Cross-checking a
computed diff against that feed is worth doing -- it is how a parser
regression that quietly drops records gets caught -- but it belongs in the
OFAC adapter, as one publisher's answer to a question every source has to
answer, rather than in the general shape of a diff.
The output
ofac_sdn: 19015 -> 19023 records, 12 added, 4 removed, 5 modified (0.1% of the previous list)
+ ofac_sdn:41234 IVANOV, Ivan Ivanovich [SDGT]
- ofac_sdn:2674 ABBAS, Abu [SDGT]
~ ofac_sdn:36 names +1, programs +1
That is diff.to_s, and #summary is its first line on its own -- the one
a sync wrapper logs. There is no CLI to print either from, #36 being closed
as not planned, so the human-readable form is a method on the object and a
rake task or a scheduled job prints it. #to_h is the machine-readable form
of the same thing, JSON-ready and carrying the two snapshots' checksums so a
diff says which pair of list versions produced it.
There is no .from_h: a diff is derived rather than stored, and those two
checksums are what makes it reproducible -- keep them and the diff can
always be computed again, keep the diff and you have a copy of an answer
nobody can check.
Instances are frozen on construction and compare by value.
Defined Under Namespace
Classes: Change
Instance Attribute Summary collapse
-
#added ⇒ Array<T.untyped>
readonly
Newly listed: on the new snapshot, not on the old, sorted by id.
-
#from ⇒ Storage::Meta?
readonly
What the old snapshot was: fetched_at, checksum, record_count.
-
#modified ⇒ Array<Change>
readonly
Amended, with the fields that moved.
-
#removed ⇒ Array<T.untyped>
readonly
Delisted: on the old snapshot, not on the new.
- #source ⇒ Symbol readonly
-
#to ⇒ Storage::Meta
readonly
What the new snapshot is, which is what screening runs against now.
Class Method Summary collapse
-
.call(source = nil, from: nil, to: nil, store: nil) ⇒ T.attached_class
Sugar, and what ActiveSanction.diff calls:.
Instance Method Summary collapse
- #==(other) ⇒ Boolean (also: #eql?)
- #any? ⇒ Boolean
-
#baseline? ⇒ Boolean
No previous snapshot: the source was synced for the first time, and this says what it holds rather than claiming every record on it is new.
-
#changed ⇒ Array<T.untyped>
What a consuming service should re-screen its book against: the records that are on the list now and were not, or were not the same.
-
#churn ⇒ Float?
How much of the previous list moved, as a fraction.
-
#details(limit: nil) ⇒ Array<String>
One line per record that moved, marked
+,-or~. - #empty? ⇒ Boolean
- #hash ⇒ Integer
-
#initialize(to:, from: nil, source: nil) ⇒ void
constructor
from:is nil for a first sync, which is a baseline rather than a list of additions -- see the class comment. - #inspect ⇒ String
-
#size ⇒ Integer
Records that moved, in either direction.
-
#summary ⇒ String
The one line at the top of #to_s, and the line worth logging on its own after a sync.
- #to_h ⇒ Hash{Symbol => T.untyped}
- #to_s ⇒ String
Constructor Details
#initialize(to:, from: nil, source: nil) ⇒ void
from: is nil for a first sync, which is a baseline rather than a list
of additions -- see the class comment. source: is optional and is
checked against the snapshots rather than trusted, since a diff of the
wrong pair of lists reports every record on both as having moved.
156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/active_sanction/diff.rb', line 156 def initialize(to:, from: nil, source: nil) current = snapshot!(:to, to) previous = from.nil? ? nil : snapshot!(:from, from) @source = T.let(source!(source, previous, current), Symbol) @from = T.let(previous && Storage::Meta.from_snapshot(previous), T.nilable(Storage::Meta)) @to = T.let(Storage::Meta.from_snapshot(current), Storage::Meta) added, removed, modified = compare(previous, current) @added = T.let(added, T::Array[T.untyped]) @removed = T.let(removed, T::Array[T.untyped]) @modified = T.let(modified, T::Array[Change]) freeze end |
Instance Attribute Details
#added ⇒ Array<T.untyped> (readonly)
Newly listed: on the new snapshot, not on the old, sorted by id.
106 107 108 |
# File 'lib/active_sanction/diff.rb', line 106 def added @added end |
#from ⇒ Storage::Meta? (readonly)
What the old snapshot was: fetched_at, checksum, record_count. Nil for a baseline. The whole snapshot is deliberately not held -- a diff of eleven records would otherwise pin two lists and tens of megabytes of entities in memory for as long as anything holds it.
121 122 123 |
# File 'lib/active_sanction/diff.rb', line 121 def from @from end |
#modified ⇒ Array<Change> (readonly)
Amended, with the fields that moved. See Diff::Change.
114 115 116 |
# File 'lib/active_sanction/diff.rb', line 114 def modified @modified end |
#removed ⇒ Array<T.untyped> (readonly)
Delisted: on the old snapshot, not on the new.
110 111 112 |
# File 'lib/active_sanction/diff.rb', line 110 def removed @removed end |
#source ⇒ Symbol (readonly)
102 103 104 |
# File 'lib/active_sanction/diff.rb', line 102 def source @source end |
#to ⇒ Storage::Meta (readonly)
What the new snapshot is, which is what screening runs against now.
125 126 127 |
# File 'lib/active_sanction/diff.rb', line 125 def to @to end |
Class Method Details
.call(source = nil, from: nil, to: nil, store: nil) ⇒ T.attached_class
Sugar, and what ActiveSanction.diff calls:
ActiveSanction.diff(:ofac_sdn, from: old, to: new)
ActiveSanction.diff(:ofac_sdn, from: old) # `to:` is what is stored now
ActiveSanction.diff(from: old, to: new) # the source comes from the snapshots
to: defaults to the stored snapshot because that is what a re-screen is
about to run against, and it raises rather than defaulting to nothing:
diffing against a list that is not there would report every record on it
as delisted, which is a clean report for every customer on it.
138 139 140 141 |
# File 'lib/active_sanction/diff.rb', line 138 def self.call(source = nil, from: nil, to: nil, store: nil) key = source.nil? ? nil : Sources::Definition.key!(source) new(source: key, from: from, to: to || stored!(key, store)) end |
Instance Method Details
#==(other) ⇒ Boolean Also known as: eql?
252 253 254 255 256 |
# File 'lib/active_sanction/diff.rb', line 252 def ==(other) return false unless other.instance_of?(self.class) to_h == other.to_h end |
#any? ⇒ Boolean
178 |
# File 'lib/active_sanction/diff.rb', line 178 def any? = !empty? |
#baseline? ⇒ Boolean
No previous snapshot: the source was synced for the first time, and this says what it holds rather than claiming every record on it is new.
172 |
# File 'lib/active_sanction/diff.rb', line 172 def baseline? = from.nil? |
#changed ⇒ Array<T.untyped>
What a consuming service should re-screen its book against: the records that are on the list now and were not, or were not the same.
Delistings are deliberately not in here -- they are not something to
screen against, they are records to clear existing alerts on, which is
a different job done from removed. And nothing here judges a change too
small to matter: a corrected passport number and a reworded remark reach
the scorer through different paths, and a library that decided on a
host's behalf which amendments were worth re-screening would be deciding
which sanctions hits it is willing to miss.
195 |
# File 'lib/active_sanction/diff.rb', line 195 def changed = added + modified.map(&:entity) |
#churn ⇒ Float?
How much of the previous list moved, as a fraction. The number to alert on: two consecutive syncs of a live sanctions list move a fraction of a percent, so a diff that says a third of the list changed is a parse regression, an id scheme that shifted, or a publisher who reissued the file -- and all three are things to look at before re-screening anybody against the result. Nil for a baseline, and for a previous list that was empty.
205 206 207 208 209 210 |
# File 'lib/active_sanction/diff.rb', line 205 def churn count = from&.record_count return nil if count.nil? || count.zero? (size.to_f / count).round(6).to_f end |
#details(limit: nil) ⇒ Array<String>
One line per record that moved, marked +, - or ~. limit: caps how
many are returned and adds a line saying how many were not; nil returns
every one, which is what a consumer writing a report wants.
239 240 241 242 243 244 245 246 |
# File 'lib/active_sanction/diff.rb', line 239 def details(limit: nil) lines = added.map { |entity| " + #{label(entity)}" } + removed.map { |entity| " - #{label(entity)}" } + modified.map { |change| " ~ #{change}" } return lines if limit.nil? || lines.size <= limit lines.first(limit) + [" ... and #{lines.size - limit} more"] end |
#empty? ⇒ Boolean
175 |
# File 'lib/active_sanction/diff.rb', line 175 def empty? = added.empty? && removed.empty? && modified.empty? |
#hash ⇒ Integer
260 |
# File 'lib/active_sanction/diff.rb', line 260 def hash = [self.class, to_h].hash |
#inspect ⇒ String
263 |
# File 'lib/active_sanction/diff.rb', line 263 def inspect = "#<#{self.class} #{source} +#{added.size} -#{removed.size} ~#{modified.size}>" |
#size ⇒ Integer
Records that moved, in either direction.
182 |
# File 'lib/active_sanction/diff.rb', line 182 def size = added.size + removed.size + modified.size |
#summary ⇒ String
The one line at the top of #to_s, and the line worth logging on its own after a sync.
227 228 229 230 231 232 233 |
# File 'lib/active_sanction/diff.rb', line 227 def summary return "#{source}: first snapshot, #{to.record_count} records (baseline, nothing to re-screen)" if baseline? return "#{source}: #{to.record_count} records, unchanged" if empty? "#{source}: #{T.must(from).record_count} -> #{to.record_count} records, #{added.size} added, " \ "#{removed.size} removed, #{modified.size} modified#{churn_note}" end |
#to_h ⇒ Hash{Symbol => T.untyped}
213 214 215 216 217 218 219 220 221 222 |
# File 'lib/active_sanction/diff.rb', line 213 def to_h { source: source, from: from&.to_h, to: to.to_h, added: added.map(&:to_h), removed: removed.map(&:to_h), modified: modified.map(&:to_h) } end |
#to_s ⇒ String
249 |
# File 'lib/active_sanction/diff.rb', line 249 def to_s = ([summary] + details(limit: DETAIL_LIMIT)).join("\n") |