Class: ActiveSanction::Rescreen::Alert

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/rescreen/alert.rb

Overview

One subject, one record, and what moved between two list versions.

alert.subject_id      # => "cust_1"
alert.change          # => :newly_listed
alert.result          # => MatchResult, scored against the record as it is now
alert.previous_result # => nil -- there was no such record before
alert.score           # => 94.1
alert.previous_score  # => nil

puts alert
# => cust_1  newly listed  un_consolidated:6908021  BOSCO TAGANDA  94.1

The two sides, and why either may be missing

An alert is a change, so it is two screening results rather than one: previous_result is what this subject scored against the record as the old list had it, and result is what it scores against the record as the new list has it. A newly listed record has no previous side. A delisted one has no current side. Everything else has both, and the pair is what lets an alert say a subject moved from 71 to 94 rather than merely that it now matches -- which is the difference between an analyst reading a record and an analyst reading a change to one.

Both are full MatchResults, each stamped with the checksum of the list version it was scored against, so an alert is defensible the same way a screening decision is: the explanation is on it, and it adds up. #evidence is the side the alert was raised on, for a caller that wants the record and does not care which list version described it.

The side that did not clear is scored again without a cutoff, so its result may sit below the threshold its own query names -- which is exactly what a subject moving into or out of range looks like, and is the whole reason the score is carried rather than only the fact of a match.

What change says, and what it does not

It is about this subject's match, not about the record's paperwork:

  • :newly_listed -- the subject did not reach the threshold against this record before and does now. Usually because the record is new; also because an existing record gained the alias, the identifier or the date of birth that brought the subject over the line, which is the same event for a compliance team and is why it is not filed separately.
  • :delisted -- it did reach the threshold before and does not now. Usually because the record was withdrawn; also because an amendment moved it out of range. This is the half of a rescreen that a run against new records only would miss, and it is the half that lets a customer back through the door.
  • :details_changed -- it matched before, it matches now, and the record moved underneath it. The score may be identical: a program added or an address corrected changes what a hit means without changing what it scores, and deciding that such a change is too small to report would be deciding which sanctions hits a host is willing to miss.

Which of those two routes into :newly_listed and :delisted a given alert took is not guesswork -- #fields is empty when the record itself arrived or left, and names the fields that moved when it was amended.

Both snapshot ids, on every alert

A MatchResult cites the one list version it was scored against, and an alert is about two. So it carries both: previous_snapshot_id and snapshot_id are the checksums the diff was computed over, which is what makes an alert reproducible under audit -- keep the pair and the whole run can be derived again, from lists that can be identified rather than from a copy of an answer nobody can check.

Instances are frozen on construction and compare by value.

Constant Summary collapse

CHANGES =

What happened to this subject's match. See the class comment.

T.let(%i[newly_listed delisted details_changed].freeze, T::Array[Symbol])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(subject:, change:, snapshot_id:, previous_snapshot_id:, result: nil, previous_result: nil, fields: []) ⇒ void

Parameters:

  • subject (T.untyped)
  • change (T.untyped)
  • snapshot_id (T.untyped)
  • previous_snapshot_id (T.untyped)
  • result (T.untyped) (defaults to: nil)
  • previous_result (T.untyped) (defaults to: nil)
  • fields (T.untyped) (defaults to: [])


151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/active_sanction/rescreen/alert.rb', line 151

def initialize(subject:, change:, snapshot_id:, previous_snapshot_id:, result: nil, previous_result: nil,
               fields: [])
  @subject = T.let(instance!(:subject, Subject, subject), Subject)
  @change = T.let(change!(change), Symbol)
  @result = T.let(result!(:result, result), T.nilable(MatchResult))
  @previous_result = T.let(result!(:previous_result, previous_result), T.nilable(MatchResult))
  sides!
  @fields = T.let(Array(fields).map(&:to_sym).freeze, T::Array[Symbol])
  @snapshot_id = T.let(string!(:snapshot_id, snapshot_id), String)
  @previous_snapshot_id = T.let(string!(:previous_snapshot_id, previous_snapshot_id), String)
  freeze
end

Instance Attribute Details

#change ⇒ Symbol (readonly)

Returns:

  • (Symbol)


100
101
102
# File 'lib/active_sanction/rescreen/alert.rb', line 100

def change
  @change
end

#fields ⇒ Array<Symbol> (readonly)

The fields of the record that moved, in Entity's member order, or an empty array when the record was added or withdrawn whole. See Diff::Change.

Returns:

  • (Array<Symbol>)


106
107
108
# File 'lib/active_sanction/rescreen/alert.rb', line 106

def fields
  @fields
end

#previous_result ⇒ MatchResult? (readonly)

Scored against the record as the old list had it, or nil when the old list did not have it.

Returns:



116
117
118
# File 'lib/active_sanction/rescreen/alert.rb', line 116

def previous_result
  @previous_result
end

#previous_snapshot_id ⇒ String (readonly)

The checksum of the list version it was compared with.

Returns:

  • (String)


124
125
126
# File 'lib/active_sanction/rescreen/alert.rb', line 124

def previous_snapshot_id
  @previous_snapshot_id
end

#result ⇒ MatchResult? (readonly)

Scored against the record as the new list has it, or nil when the new list does not have it.

Returns:



111
112
113
# File 'lib/active_sanction/rescreen/alert.rb', line 111

def result
  @result
end

#snapshot_id ⇒ String (readonly)

The checksum of the list version this run screened against.

Returns:

  • (String)


120
121
122
# File 'lib/active_sanction/rescreen/alert.rb', line 120

def snapshot_id
  @snapshot_id
end

#subject ⇒ Subject (readonly)

The book entry this alert is about, as the caller supplied it.

Returns:



97
98
99
# File 'lib/active_sanction/rescreen/alert.rb', line 97

def subject
  @subject
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds an alert from #to_h output, accepting string keys so one survives the round-trip through JSON and back out of whatever a host stored it in.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



130
131
132
133
134
135
136
137
138
139
# File 'lib/active_sanction/rescreen/alert.rb', line 130

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Alert attribute(s): #{unknown.join(", ")}" if unknown.any?

  T.unsafe(self).new(**attributes,
                     subject: build(Subject, attributes[:subject]),
                     result: build(MatchResult, attributes[:result]),
                     previous_result: build(MatchResult, attributes[:previous_result]))
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


250
251
252
253
254
# File 'lib/active_sanction/rescreen/alert.rb', line 250

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#delisted? ⇒ Boolean

Returns:

  • (Boolean)


219
# File 'lib/active_sanction/rescreen/alert.rb', line 219

def delisted? = change == :delisted

#details_changed? ⇒ Boolean

Returns:

  • (Boolean)


222
# File 'lib/active_sanction/rescreen/alert.rb', line 222

def details_changed? = change == :details_changed

#entity ⇒ Entity

The record this alert is about, as the surviving side has it.

Returns:



181
# File 'lib/active_sanction/rescreen/alert.rb', line 181

def entity = evidence.entity

#entity_id ⇒ String

Returns:

  • (String)


184
# File 'lib/active_sanction/rescreen/alert.rb', line 184

def entity_id = entity.id

#evidence ⇒ MatchResult

The side the alert was raised on: the current one, or the previous one for a delisting, which is the version that actually matched. It is what #entity and #matched_name read, so an alert describes the record the way it looked when it crossed the threshold.

Never nil: an alert with neither side is not a change, and is refused at construction.

Returns:



177
# File 'lib/active_sanction/rescreen/alert.rb', line 177

def evidence = T.must(delisted? ? previous_result || result : result || previous_result)

#hash ⇒ Integer

Returns:

  • (Integer)


258
# File 'lib/active_sanction/rescreen/alert.rb', line 258

def hash = [self.class, to_h].hash

#inspect ⇒ String

Returns:

  • (String)


261
# File 'lib/active_sanction/rescreen/alert.rb', line 261

def inspect = "#<#{self.class} #{self}>"

#matched_name ⇒ Name

The spelling that produced the score, as its publisher wrote it. See MatchResult#matched_name.

Returns:



189
# File 'lib/active_sanction/rescreen/alert.rb', line 189

def matched_name = evidence.matched_name

#newly_listed? ⇒ Boolean

Returns:

  • (Boolean)


216
# File 'lib/active_sanction/rescreen/alert.rb', line 216

def newly_listed? = change == :newly_listed

#previous_score ⇒ Float?

What it scored against the record before, or nil if the record was not on the previous list.

Returns:

  • (Float, nil)


202
# File 'lib/active_sanction/rescreen/alert.rb', line 202

def previous_score = previous_result&.score

#score ⇒ Float?

What the subject scores against the record now, or nil if the record is no longer on the list.

Returns:

  • (Float, nil)


197
# File 'lib/active_sanction/rescreen/alert.rb', line 197

def score = result&.score

#screened_at ⇒ Time

When the run that produced this alert happened. One instant for a whole run -- see Rescreen.

Returns:

  • (Time)


213
# File 'lib/active_sanction/rescreen/alert.rb', line 213

def screened_at = evidence.screened_at

#source ⇒ Symbol

Returns:

  • (Symbol)


192
# File 'lib/active_sanction/rescreen/alert.rb', line 192

def source = entity.source

#subject_id ⇒ String

The caller's own id for the subject, which is what an alert is joined back to a book of business by.

Returns:

  • (String)


167
# File 'lib/active_sanction/rescreen/alert.rb', line 167

def subject_id = subject.id

#threshold ⇒ Float

The threshold this subject was screened at, which is half of what makes the change mean anything: the same pair of scores is a new listing at 75 and nothing at all at 95.

Returns:

  • (Float)


208
# File 'lib/active_sanction/rescreen/alert.rb', line 208

def threshold = evidence.threshold

#to_h ⇒ Hash{Symbol => T.untyped}

The documented shape. Every value is a String, a Float, an Integer, an Array or a Hash of the same, so JSON.generate(alert.to_h) needs nothing from this library and Alert.from_h(JSON.parse(json)) rebuilds exactly this object.

Returns:

  • (Hash{Symbol => T.untyped})


229
230
231
232
233
234
235
236
237
238
239
# File 'lib/active_sanction/rescreen/alert.rb', line 229

def to_h
  {
    subject: subject.to_h,
    change: change,
    fields: fields,
    result: result&.to_h,
    previous_result: previous_result&.to_h,
    snapshot_id: snapshot_id,
    previous_snapshot_id: previous_snapshot_id
  }
end

#to_s ⇒ String

One line, for the summary a human reads:

cust_1  newly listed  un_consolidated:6908021  BOSCO TAGANDA  94.1 (was 71.0)

Returns:

  • (String)


245
246
247
# File 'lib/active_sanction/rescreen/alert.rb', line 245

def to_s
  "#{subject_id}  #{change.to_s.tr("_", " ")}  #{entity_id}  #{evidence.matched_name.value}  #{movement}"
end