Class: ActiveSanction::Query

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

Overview

One screening request: what is known about the subject, and how the search is to be run.

query = ActiveSanction::Query.build(
name:          "Bosco Ntaganda",
type:          :individual,
date_of_birth: "1973",
countries:     %w[CD],
sources:       %i[ofac_sdn un_consolidated],
threshold:     75,
limit:         10
)

query.subject     # => Scorer::Subject, folded once and held
query.threshold   # => 75.0

Two kinds of field, and the line between them matters

Evidence -- the name, the type, the dates of birth, the nationalities, the identifiers -- is what the scorer compares against a record, and it belongs to Scorer::Subject, which this builds and holds. Search options -- sources, threshold, limit -- decide which records are looked at and how many come back, and they never touch a comparison.

Keeping them apart is what lets a stored decision be re-derived: the evidence says what was screened and the options say what the run was willing to return, and an audit needs both separately. It is also why Subject carries no threshold -- see the note at the end of that class.

Singular and plural spellings are both accepted

date_of_birth: and dates_of_birth:, country:, countries: and nationalities:, identifier: and identifiers: all mean the same thing. A caller with one date writes the singular and a caller with three writes the plural, and neither should have to remember which this library prefers. #to_h emits the plural, canonical spelling.

That resolution happens in .build and .from_h, which is what every screening call goes through -- Matcher#screen builds one of these out of whatever it was handed. .new takes the canonical names and nothing else, in the manner of Scorer::Weights: one constructor states the shape and one accepts what a caller wrote.

Defaults come from configuration, once, here

A query with no threshold: takes config.screening_threshold and one with no limit: takes config.screening_limit, both read at construction and then fixed. Nothing downstream reads a global: a Matcher screens the numbers on the query it was given, so a configuration changed mid-batch cannot produce a run that is half one threshold and half another.

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: [], sources: nil, threshold: nil, limit: nil) ⇒ void

Only name is required; see Scorer::Subject on why that is the shape of the problem rather than a convenience.

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: [])
  • sources (T.untyped) (defaults to: nil)
  • threshold (T.untyped) (defaults to: nil)
  • limit (T.untyped) (defaults to: nil)


187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/active_sanction/query.rb', line 187

def initialize(name:, type: nil, dates_of_birth: [], nationalities: [], identifiers: [],
               sources: nil, threshold: nil, limit: nil)
  @subject = T.let(
    Scorer::Subject.new(name: name, type: type, dates_of_birth: dates_of_birth,
                        nationalities: nationalities, identifiers: identifiers),
    Scorer::Subject
  )
  @sources = T.let(sources!(sources), T.nilable(T::Array[Symbol]))
  @threshold = T.let(threshold!(threshold), Float)
  @limit = T.let(limit!(limit), Integer)
  freeze
end

Instance Attribute Details

#limit ⇒ Integer (readonly)

How many results to return, highest score first.

Returns:

  • (Integer)


113
114
115
# File 'lib/active_sanction/query.rb', line 113

def limit
  @limit
end

#sources ⇒ Array<Symbol>? (readonly)

Which lists to screen against, or nil for every list the matcher holds. Naming one it does not hold is an error rather than a shorter answer -- see Matcher.

Returns:

  • (Array<Symbol>, nil)


104
105
106
# File 'lib/active_sanction/query.rb', line 104

def sources
  @sources
end

#subject ⇒ Scorer::Subject (readonly)

The evidence, folded once. Every comparison in a screening run happens against this one object rather than against a name re-folded per candidate.

Returns:



98
99
100
# File 'lib/active_sanction/query.rb', line 98

def subject
  @subject
end

#threshold ⇒ Float (readonly)

0..100. The lowest score worth reporting, and the number that decides what a screening call costs -- see Scorer.

Returns:

  • (Float)


109
110
111
# File 'lib/active_sanction/query.rb', line 109

def threshold
  @threshold
end

Class Method Details

.build(value = nil, **overrides) ⇒ Query

Whatever a caller had, as a Query:

Query.build("Bosco Ntaganda")
Query.build(name: "Bosco Ntaganda", threshold: 80)
Query.build(query, limit: 5)         # the same query, with one option changed

A bare String or Name is a query about that name and nothing else, which is what a batch of names is a list of.

Parameters:

  • value (T.untyped) (defaults to: nil)
  • overrides (T.untyped)

Returns:



127
128
129
130
131
132
133
134
# File 'lib/active_sanction/query.rb', line 127

def build(value = nil, **overrides)
  case value
  when Query then overrides.empty? ? value : T.unsafe(value).merge(**overrides)
  when Hash then from_h(normalize(value).merge(normalize(overrides)))
  when nil then from_h(overrides)
  else from_h(normalize(overrides).merge(name: value))
  end
end

.from_h(hash) ⇒ Query

Rebuilds a query from #to_h output, accepting string keys so one stored in an audit record survives the round-trip through JSON.

Parameters:

  • hash (T.untyped)

Returns:

Raises:



139
140
141
142
143
144
145
146
147
148
# File 'lib/active_sanction/query.rb', line 139

def from_h(hash)
  attributes = normalize(hash)
  unknown = attributes.keys - MEMBERS
  raise QueryError, "unknown Query attribute(s): #{unknown.join(", ")}" if unknown.any?

  # `new(**hash)` past a required keyword parameter is one of the few
  # things Sorbet cannot check statically. #initialize validates what
  # arrives, which is where a bad round-trip is caught.
  T.unsafe(self).new(**attributes)
end

Instance Method Details

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

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


232
233
234
235
236
# File 'lib/active_sanction/query.rb', line 232

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

  to_h == other.to_h
end

#dates_of_birth ⇒ Array<PartialDate>

Returns:



213
# File 'lib/active_sanction/query.rb', line 213

def dates_of_birth = subject.dates_of_birth

#form ⇒ Normalizer::Form

The folded name every comparison runs against, and what a Matcher hands the index rather than the string it came from.

Returns:



224
# File 'lib/active_sanction/query.rb', line 224

def form = subject.form

#hash ⇒ Integer

Returns:

  • (Integer)


240
# File 'lib/active_sanction/query.rb', line 240

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

#identifiers ⇒ Array<Identifier>

Returns:



219
# File 'lib/active_sanction/query.rb', line 219

def identifiers = subject.identifiers

#inspect ⇒ String

Returns:

  • (String)


243
# File 'lib/active_sanction/query.rb', line 243

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

#merge(**overrides) ⇒ Query

This query with some fields replaced, which is how a batch applies one threshold to a list of names.

Parameters:

  • overrides (T.untyped)

Returns:



203
# File 'lib/active_sanction/query.rb', line 203

def merge(**overrides) = self.class.build(to_h, **overrides)

#name ⇒ String

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

Returns:

  • (String)


207
# File 'lib/active_sanction/query.rb', line 207

def name = subject.name

#nationalities ⇒ Array<String>

Returns:

  • (Array<String>)


216
# File 'lib/active_sanction/query.rb', line 216

def nationalities = subject.nationalities

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

Returns:

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


227
228
229
# File 'lib/active_sanction/query.rb', line 227

def to_h
  subject.to_h.merge(sources: sources, threshold: threshold, limit: limit)
end

#type ⇒ Symbol?

Returns:

  • (Symbol, nil)


210
# File 'lib/active_sanction/query.rb', line 210

def type = subject.type