Class: ActiveSanction::Scorer::Result

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

Overview

One entity scored against one subject, and the account of how.

result = ActiveSanction::Scorer.call(subject, entity)

result.score        # => 87.4
result.entity.id    # => "ofac_sdn:2674"
result.name.value   # => "AERO-CARIBBEAN"
result.explanation.map(&:to_s)
# => ["+91.2 name: matched alias 'AERO-CARIBBEAN' (aka)",
#     "+4.0 dob: date of birth 1948 overlaps listed 1948-12-10",
#     "-8.0 nationality: query RU vs listed EG"]

The score is the explanation

score is not stored beside the reasons, it is the sum of them, rounded once. There is no arithmetic anywhere in this library that can move one without the other, which is the point: a compliance officer has to answer "why did this score 87?" to an examiner, and a number that merely travels alongside a list of reasons is a number that can come apart from them in a later release and be wrong quietly for a year.

So the explanation is never empty -- the blended name similarity is always the first reason, even when nothing else was known -- and it always adds up.

name is the specific spelling that produced the score

An entity's score is the best of its names, and this is the one that won. It matters more than it looks: OFAC ships more aliases than primary names, so the answer to "what did we match?" is usually an alias, and a report that quoted the primary name instead would be describing a comparison that never happened.

form is that name folded, which is the string the scorers actually compared. Both are here for the reason Normalizer::Form carries both: the published spelling is what a person reads and the folded one is what a person checks.

What this is not

It is not MatchResult (#33). This carries what the scorer knows -- a score, a name, an entity, an explanation -- and nothing about the screening run that produced it. The snapshot checksum, the matcher version, the thresholds and the backend all belong to the public API above this one, which is where an audit record is assembled.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(entity:, name:, form:, explanation:) ⇒ void

Parameters:

Raises:



83
84
85
86
87
88
89
90
91
92
# File 'lib/active_sanction/scorer/result.rb', line 83

def initialize(entity:, name:, form:, explanation:)
  raise InvalidArgument, "a result needs at least one reason" if explanation.empty?

  @entity = entity
  @name = name
  @form = form
  @explanation = T.let(explanation.dup.freeze, T::Array[Reason])
  @score = T.let(explanation.sum(&:contribution).round(Reason::PRECISION).to_f, Float)
  freeze
end

Instance Attribute Details

#entity ⇒ Entity (readonly)

Returns:



61
62
63
# File 'lib/active_sanction/scorer/result.rb', line 61

def entity
  @entity
end

#explanation ⇒ Array<Reason> (readonly)

Never empty. See the class comment.

Returns:



77
78
79
# File 'lib/active_sanction/scorer/result.rb', line 77

def explanation
  @explanation
end

#form ⇒ Normalizer::Form (readonly)

That name folded -- the string the comparison ran on.

Returns:



69
70
71
# File 'lib/active_sanction/scorer/result.rb', line 69

def form
  @form
end

#name ⇒ Name (readonly)

The name that scored highest, as its publisher wrote it.

Returns:



65
66
67
# File 'lib/active_sanction/scorer/result.rb', line 65

def name
  @name
end

#score ⇒ Float (readonly)

0..100, one decimal place, and equal to the sum of the explanation.

Returns:

  • (Float)


73
74
75
# File 'lib/active_sanction/scorer/result.rb', line 73

def score
  @score
end

Instance Method Details

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

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


115
116
117
118
119
# File 'lib/active_sanction/scorer/result.rb', line 115

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

  entity == other.entity && name == other.name && explanation == other.explanation
end

#hash ⇒ Integer

Returns:

  • (Integer)


123
# File 'lib/active_sanction/scorer/result.rb', line 123

def hash = [self.class, entity, name, explanation].hash

#inspect ⇒ String

Returns:

  • (String)


126
# File 'lib/active_sanction/scorer/result.rb', line 126

def inspect = "#<#{self.class} #{score} #{name.value.inspect} (#{entity.id})>"

#penalties ⇒ Array<Reason>

The reasons that lowered the score, which is the half of an explanation a reviewer clearing an alert reads first.

Returns:



101
# File 'lib/active_sanction/scorer/result.rb', line 101

def penalties = explanation.select(&:penalty?)

#source ⇒ Symbol

The list this entity came from, which every hit has to name.

Returns:

  • (Symbol)


96
# File 'lib/active_sanction/scorer/result.rb', line 96

def source = entity.source

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

Returns:

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


104
105
106
107
108
109
110
111
112
# File 'lib/active_sanction/scorer/result.rb', line 104

def to_h
  {
    score: score,
    entity_id: entity.id,
    source: source,
    name: name.to_h,
    explanation: explanation.map(&:to_h)
  }
end