Class: ActiveSanction::Scorer::Subject

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

Overview

What the caller knows about the person or company being screened.

subject = ActiveSanction::Scorer::Subject.new(
name:           "Bosco Ntaganda",
type:           :individual,
dates_of_birth: "1973",
nationalities:  %w[CD],
identifiers:    [{ kind: :passport, value: "AB-123 456" }]
)

subject.form.value   # => "bosco ntaganda"

One side of every comparison the scorer makes, and the mirror image of the Entity on the other side: the same four kinds of evidence, arriving from an application's own customer record rather than from a government.

Only name is required, and that is the shape of the problem

Most callers have a name and little else, and most records carry less than that -- Canada supplies no aliases and often no date of birth. So every field but the name is optional on both sides, and a field absent on either side is neutral rather than a conflict. See Adjustments, where that rule is the difference between a screening tool and a tool that systematically under-scores the sparser lists.

type decides two things

It is the entity type the caller is asking about, and it does two jobs that are easy to confuse. It selects the normalizer's stoplists, so a company name is folded with its legal form stripped -- and both sides of a comparison have to be folded the same way, which is why the scorer folds each candidate name under its own entity's type. And it filters: a subject that says :individual is never scored against a vessel, at any name similarity. See Scorer.

Passing no type is a legitimate answer and a different question -- no stoplist, no filter -- rather than a worse one.

The fold happens once, here

form is the folded name, produced by the one Normalizer.call every other stage uses, and held for the life of the subject. A screening call compares one subject against a few hundred candidates, and folding the query per candidate would be the same string folded a few hundred times.

A caller that has already folded a name passes the Form, which is what screening one name against several indexes should do.

Where Query fits

This is the scorer's input, not the library's public screening API. The Query object (#33) validates what a host application sends -- a threshold, a limit, a source filter -- and builds one of these for the matcher to score with. Everything on this class is evidence about a subject; nothing on it is a search option.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, type: nil, dates_of_birth: [], nationalities: [], identifiers: []) ⇒ void

name is a String, a Name or an already-folded Form. The three collections each accept a single value as a collection of one, since dates_of_birth: "1973" is what a caller with one date writes.

Dates accept anything PartialDate reads, including the free text these lists publish; identifiers accept an Identifier, its hash, or a bare document number, which becomes an identifier of unstated kind.

Parameters:

  • name (T.untyped)
  • type (T.untyped) (defaults to: nil)
  • dates_of_birth (T.untyped) (defaults to: [])
  • nationalities (T.untyped) (defaults to: [])
  • identifiers (T.untyped) (defaults to: [])


116
117
118
119
120
121
122
123
124
# File 'lib/active_sanction/scorer/subject.rb', line 116

def initialize(name:, type: nil, dates_of_birth: [], nationalities: [], identifiers: [])
  @type = T.let(type!(type), T.nilable(Symbol))
  @form = T.let(form!(name), Normalizer::Form)
  @dates_of_birth = T.let(Array(dates_of_birth).map { |value| date!(value) }.freeze, T::Array[PartialDate])
  @nationalities = T.let(strings(nationalities), T::Array[String])
  @identifiers = T.let(Array(identifiers).map { |value| identifier!(value) }.freeze, T::Array[Identifier])
  @countries = T.let(resolve(@nationalities), T::Array[String])
  freeze
end

Instance Attribute Details

#countries ⇒ Array<String> (readonly)

The alpha-2 codes #nationalities resolved to, which may be shorter than the list it came from: a value Country does not recognize is dropped here rather than guessed at, and the scorer treats a subject whose countries did not all resolve as one that cannot contradict a record. See Adjustments.

Returns:

  • (Array<String>)


136
137
138
# File 'lib/active_sanction/scorer/subject.rb', line 136

def countries
  @countries
end

#dates_of_birth ⇒ Array<PartialDate> (readonly)

Returns:



85
86
87
# File 'lib/active_sanction/scorer/subject.rb', line 85

def dates_of_birth
  @dates_of_birth
end

#form ⇒ Normalizer::Form (readonly)

The folded name every comparison runs against.

Returns:



79
80
81
# File 'lib/active_sanction/scorer/subject.rb', line 79

def form
  @form
end

#identifiers ⇒ Array<Identifier> (readonly)

Returns:



94
95
96
# File 'lib/active_sanction/scorer/subject.rb', line 94

def identifiers
  @identifiers
end

#nationalities ⇒ Array<String> (readonly)

As published by the caller, in the caller's own vocabulary: RU, Russia and Russian Federation are all fine here. See #countries for the resolved form the scorer compares on.

Returns:

  • (Array<String>)


91
92
93
# File 'lib/active_sanction/scorer/subject.rb', line 91

def nationalities
  @nationalities
end

#type ⇒ Symbol? (readonly)

Returns:

  • (Symbol, nil)


82
83
84
# File 'lib/active_sanction/scorer/subject.rb', line 82

def type
  @type
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



97
98
99
100
101
102
103
# File 'lib/active_sanction/scorer/subject.rb', line 97

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

  T.unsafe(self).new(**attributes)
end

Instance Method Details

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

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


161
162
163
164
165
# File 'lib/active_sanction/scorer/subject.rb', line 161

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

  to_h == other.to_h
end

#countries? ⇒ Boolean

True when every nationality the caller gave resolved to a country. A conflict penalty is only applied when both sides can say this.

Returns:

  • (Boolean)


141
# File 'lib/active_sanction/scorer/subject.rb', line 141

def countries? = nationalities.any? && countries.size == nationalities.uniq.size

#dates_of_birth? ⇒ Boolean

Returns:

  • (Boolean)


144
# File 'lib/active_sanction/scorer/subject.rb', line 144

def dates_of_birth? = dates_of_birth.any?

#hash ⇒ Integer

Returns:

  • (Integer)


169
# File 'lib/active_sanction/scorer/subject.rb', line 169

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

#identifiers? ⇒ Boolean

Returns:

  • (Boolean)


147
# File 'lib/active_sanction/scorer/subject.rb', line 147

def identifiers? = identifiers.any?

#inspect ⇒ String

Returns:

  • (String)


172
# File 'lib/active_sanction/scorer/subject.rb', line 172

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

#name ⇒ String

The name as the caller wrote it, which is what a report quotes back.

Returns:

  • (String)


128
# File 'lib/active_sanction/scorer/subject.rb', line 128

def name = form.original

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

Returns:

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


150
151
152
153
154
155
156
157
158
# File 'lib/active_sanction/scorer/subject.rb', line 150

def to_h
  {
    name: name,
    type: type,
    dates_of_birth: dates_of_birth.map(&:to_h),
    nationalities: nationalities,
    identifiers: identifiers.map(&:to_h)
  }
end